Commit 29ba61a3 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '214824-fix-web-ide-open-file-race-condition' into 'master'

Resolve "WebIDE displays blank file incorrectly"

Closes #214824

See merge request gitlab-org/gitlab!33391
parents 0662980b 34c6c338
...@@ -185,7 +185,6 @@ export default { ...@@ -185,7 +185,6 @@ export default {
'setFileLanguage', 'setFileLanguage',
'setEditorPosition', 'setEditorPosition',
'setFileViewMode', 'setFileViewMode',
'updateViewer',
'removePendingTab', 'removePendingTab',
'triggerFilesChange', 'triggerFilesChange',
'addTempImage', 'addTempImage',
...@@ -241,7 +240,7 @@ export default { ...@@ -241,7 +240,7 @@ export default {
}); });
}, },
setupEditor() { setupEditor() {
if (!this.file || !this.editor.instance) return; if (!this.file || !this.editor.instance || this.file.loading) return;
const head = this.getStagedFile(this.file.path); const head = this.getStagedFile(this.file.path);
......
...@@ -65,7 +65,7 @@ export const getFileData = ( ...@@ -65,7 +65,7 @@ export const getFileData = (
if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded)) if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded))
return Promise.resolve(); return Promise.resolve();
commit(types.TOGGLE_LOADING, { entry: file }); commit(types.TOGGLE_LOADING, { entry: file, forceValue: true });
const url = joinPaths( const url = joinPaths(
gon.relative_url_root || '/', gon.relative_url_root || '/',
...@@ -84,10 +84,8 @@ export const getFileData = ( ...@@ -84,10 +84,8 @@ export const getFileData = (
if (data) commit(types.SET_FILE_DATA, { data, file }); if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path); if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
if (makeFileActive) dispatch('setFileActive', path); if (makeFileActive) dispatch('setFileActive', path);
commit(types.TOGGLE_LOADING, { entry: file });
}) })
.catch(() => { .catch(() => {
commit(types.TOGGLE_LOADING, { entry: file });
dispatch('setErrorMessage', { dispatch('setErrorMessage', {
text: __('An error occurred while loading the file.'), text: __('An error occurred while loading the file.'),
action: payload => action: payload =>
...@@ -95,6 +93,9 @@ export const getFileData = ( ...@@ -95,6 +93,9 @@ export const getFileData = (
actionText: __('Please try again'), actionText: __('Please try again'),
actionPayload: { path, makeFileActive }, actionPayload: { path, makeFileActive },
}); });
})
.finally(() => {
commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
}); });
}; };
...@@ -106,45 +107,41 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = ...@@ -106,45 +107,41 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
const file = state.entries[path]; const file = state.entries[path];
const stagedFile = state.stagedFiles.find(f => f.path === path); const stagedFile = state.stagedFiles.find(f => f.path === path);
return new Promise((resolve, reject) => { const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); commit(types.TOGGLE_LOADING, { entry: file, forceValue: true });
service return service
.getRawFileData(fileDeletedAndReadded ? stagedFile : file) .getRawFileData(fileDeletedAndReadded ? stagedFile : file)
.then(raw => { .then(raw => {
if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded))
commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded });
if (file.mrChange && file.mrChange.new_file === false) { if (file.mrChange && file.mrChange.new_file === false) {
const baseSha = const baseSha =
(getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || '';
service return service.getBaseRawFileData(file, baseSha).then(baseRaw => {
.getBaseRawFileData(file, baseSha) commit(types.SET_FILE_BASE_RAW_DATA, {
.then(baseRaw => { file,
commit(types.SET_FILE_BASE_RAW_DATA, { baseRaw,
file, });
baseRaw, return raw;
});
resolve(raw);
})
.catch(e => {
reject(e);
});
} else {
resolve(raw);
}
})
.catch(() => {
dispatch('setErrorMessage', {
text: __('An error occurred while loading the file content.'),
action: payload =>
dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
actionPayload: { path },
}); });
reject(); }
return raw;
})
.catch(e => {
dispatch('setErrorMessage', {
text: __('An error occurred while loading the file content.'),
action: payload =>
dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
actionPayload: { path },
}); });
}); throw e;
})
.finally(() => {
commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
});
}; };
export const changeFileContent = ({ commit, state, getters }, { path, content }) => { export const changeFileContent = ({ commit, state, getters }, { path, content }) => {
......
---
title: Resolve "WebIDE displays blank file incorrectly"
merge_request: 33391
type: fixed
/* useful for timing promises when jest fakeTimers are not reliable enough */
export default timeout =>
new Promise(resolve => {
jest.useRealTimers();
setTimeout(resolve, timeout);
jest.useFakeTimers();
});
...@@ -4,19 +4,25 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -4,19 +4,25 @@ import MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import { Range } from 'monaco-editor'; import { Range } from 'monaco-editor';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores'; import { createStoreOptions } from '~/ide/stores';
import RepoEditor from '~/ide/components/repo_editor.vue'; import RepoEditor from '~/ide/components/repo_editor.vue';
import Editor from '~/ide/lib/editor'; import Editor from '~/ide/lib/editor';
import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants'; import {
leftSidebarViews,
FILE_VIEW_MODE_EDITOR,
FILE_VIEW_MODE_PREVIEW,
viewerTypes,
} from '~/ide/constants';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { file } from '../helpers'; import { file } from '../helpers';
import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
import waitUsingRealTimer from 'helpers/wait_using_real_timer';
describe('RepoEditor', () => { describe('RepoEditor', () => {
let vm; let vm;
let store; let store;
let mockActions;
const waitForEditorSetup = () => const waitForEditorSetup = () =>
new Promise(resolve => { new Promise(resolve => {
...@@ -30,6 +36,10 @@ describe('RepoEditor', () => { ...@@ -30,6 +36,10 @@ describe('RepoEditor', () => {
vm = createComponentWithStore(Vue.extend(RepoEditor), store, { vm = createComponentWithStore(Vue.extend(RepoEditor), store, {
file: store.state.openFiles[0], file: store.state.openFiles[0],
}); });
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
vm.$mount(); vm.$mount();
}; };
...@@ -43,21 +53,12 @@ describe('RepoEditor', () => { ...@@ -43,21 +53,12 @@ describe('RepoEditor', () => {
}; };
beforeEach(() => { beforeEach(() => {
mockActions = {
getFileData: jest.fn().mockResolvedValue(),
getRawFileData: jest.fn().mockResolvedValue(),
};
const f = { const f = {
...file(), ...file(),
viewMode: FILE_VIEW_MODE_EDITOR, viewMode: FILE_VIEW_MODE_EDITOR,
}; };
const storeOptions = createStoreOptions(); const storeOptions = createStoreOptions();
storeOptions.actions = {
...storeOptions.actions,
...mockActions,
};
store = new Vuex.Store(storeOptions); store = new Vuex.Store(storeOptions);
f.active = true; f.active = true;
...@@ -438,7 +439,7 @@ describe('RepoEditor', () => { ...@@ -438,7 +439,7 @@ describe('RepoEditor', () => {
vm.initEditor(); vm.initEditor();
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
expect(mockActions.getFileData).not.toHaveBeenCalled(); expect(vm.getFileData).not.toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -449,10 +450,11 @@ describe('RepoEditor', () => { ...@@ -449,10 +450,11 @@ describe('RepoEditor', () => {
vm.file.raw = ''; vm.file.raw = '';
vm.initEditor(); vm.initEditor();
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
expect(mockActions.getFileData).toHaveBeenCalled(); expect(vm.getFileData).toHaveBeenCalled();
expect(mockActions.getRawFileData).toHaveBeenCalled(); expect(vm.getRawFileData).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -464,8 +466,8 @@ describe('RepoEditor', () => { ...@@ -464,8 +466,8 @@ describe('RepoEditor', () => {
vm.initEditor(); vm.initEditor();
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
expect(mockActions.getFileData).not.toHaveBeenCalled(); expect(vm.getFileData).not.toHaveBeenCalled();
expect(mockActions.getRawFileData).not.toHaveBeenCalled(); expect(vm.getRawFileData).not.toHaveBeenCalled();
expect(vm.editor.createInstance).not.toHaveBeenCalled(); expect(vm.editor.createInstance).not.toHaveBeenCalled();
}) })
.then(done) .then(done)
...@@ -526,6 +528,65 @@ describe('RepoEditor', () => { ...@@ -526,6 +528,65 @@ describe('RepoEditor', () => {
}); });
}); });
describe('populates editor with the fetched content', () => {
beforeEach(() => {
vm.getRawFileData.mockRestore();
});
const createRemoteFile = name => ({
...file(name),
tmpFile: false,
});
it('after switching viewer from edit to diff', async () => {
jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
expect(vm.file.loading).toBe(true);
// switching from edit to diff mode usually triggers editor initialization
store.state.viewer = viewerTypes.diff;
// we delay returning the file to make sure editor doesn't initialize before we fetch file content
await waitUsingRealTimer(10);
return 'rawFileData123\n';
});
const f = createRemoteFile('newFile');
Vue.set(store.state.entries, f.path, f);
vm.file = f;
// use the real timer to accurately simulate the race condition
await waitUsingRealTimer(20);
expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
});
it('after opening multiple files at the same time', async () => {
const fileA = createRemoteFile('fileA');
const fileB = createRemoteFile('fileB');
Vue.set(store.state.entries, fileA.path, fileA);
Vue.set(store.state.entries, fileB.path, fileB);
jest
.spyOn(service, 'getRawFileData')
.mockImplementationOnce(async () => {
// opening fileB while the content of fileA is still being fetched
vm.file = fileB;
return 'fileA-rawContent\n';
})
.mockImplementationOnce(async () => {
// we delay returning fileB content to make sure the editor doesn't initialize prematurely
await waitUsingRealTimer(10);
return 'fileB-rawContent\n';
});
vm.file = fileA;
// use the real timer to accurately simulate the race condition
await waitUsingRealTimer(20);
expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n');
});
});
describe('onPaste', () => { describe('onPaste', () => {
const setFileName = name => { const setFileName = name => {
Vue.set(vm, 'file', { Vue.set(vm, 'file', {
...@@ -632,8 +693,8 @@ describe('RepoEditor', () => { ...@@ -632,8 +693,8 @@ describe('RepoEditor', () => {
return waitForEditorSetup().then(() => { return waitForEditorSetup().then(() => {
expect(vm.rules).toEqual(monacoRules); expect(vm.rules).toEqual(monacoRules);
expect(vm.model.options).toMatchObject(monacoRules); expect(vm.model.options).toMatchObject(monacoRules);
expect(mockActions.getFileData).not.toHaveBeenCalled(); expect(vm.getFileData).not.toHaveBeenCalled();
expect(mockActions.getRawFileData).not.toHaveBeenCalled(); expect(vm.getRawFileData).not.toHaveBeenCalled();
}); });
}, },
); );
...@@ -649,13 +710,13 @@ describe('RepoEditor', () => { ...@@ -649,13 +710,13 @@ describe('RepoEditor', () => {
createComponent(); createComponent();
return waitForEditorSetup().then(() => { return waitForEditorSetup().then(() => {
expect(mockActions.getFileData.mock.calls.map(([, args]) => args)).toEqual([ expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([
{ makeFileActive: false, path: 'foo/bar/baz/.editorconfig' }, { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' },
{ makeFileActive: false, path: 'foo/bar/.editorconfig' }, { makeFileActive: false, path: 'foo/bar/.editorconfig' },
{ makeFileActive: false, path: 'foo/.editorconfig' }, { makeFileActive: false, path: 'foo/.editorconfig' },
{ makeFileActive: false, path: '.editorconfig' }, { makeFileActive: false, path: '.editorconfig' },
]); ]);
expect(mockActions.getRawFileData.mock.calls.map(([, args]) => args)).toEqual([ expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([
{ path: 'foo/bar/baz/.editorconfig' }, { path: 'foo/bar/baz/.editorconfig' },
{ path: 'foo/bar/.editorconfig' }, { path: 'foo/bar/.editorconfig' },
{ path: 'foo/.editorconfig' }, { path: 'foo/.editorconfig' },
......
...@@ -446,6 +446,54 @@ describe('IDE store file actions', () => { ...@@ -446,6 +446,54 @@ describe('IDE store file actions', () => {
}) })
.catch(done.fail); .catch(done.fail);
}); });
describe('sets file loading to true', () => {
let loadingWhenGettingRawData;
let loadingWhenGettingBaseRawData;
beforeEach(() => {
loadingWhenGettingRawData = undefined;
loadingWhenGettingBaseRawData = undefined;
jest.spyOn(service, 'getRawFileData').mockImplementation(f => {
loadingWhenGettingRawData = f.loading;
return Promise.resolve('raw');
});
jest.spyOn(service, 'getBaseRawFileData').mockImplementation(f => {
loadingWhenGettingBaseRawData = f.loading;
return Promise.resolve('rawBase');
});
});
it('when getting raw file data', async () => {
expect(tmpFile.loading).toBe(false);
await store.dispatch('getRawFileData', { path: tmpFile.path });
expect(loadingWhenGettingRawData).toBe(true);
expect(tmpFile.loading).toBe(false);
});
it('when getting base raw file data', async () => {
tmpFile.mrChange = { new_file: false };
expect(tmpFile.loading).toBe(false);
await store.dispatch('getRawFileData', { path: tmpFile.path });
expect(loadingWhenGettingBaseRawData).toBe(true);
expect(tmpFile.loading).toBe(false);
});
it('when file was already loading', async () => {
tmpFile.loading = true;
await store.dispatch('getRawFileData', { path: tmpFile.path });
expect(loadingWhenGettingRawData).toBe(true);
expect(tmpFile.loading).toBe(false);
});
});
}); });
describe('return JSON', () => { describe('return JSON', () => {
...@@ -489,6 +537,12 @@ describe('IDE store file actions', () => { ...@@ -489,6 +537,12 @@ describe('IDE store file actions', () => {
}); });
}); });
}); });
it('toggles loading off after error', async () => {
await expect(store.dispatch('getRawFileData', { path: tmpFile.path })).rejects.toThrow();
expect(tmpFile.loading).toBe(false);
});
}); });
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment