diff --git a/packages/super-editor/src/components/SuperEditor.test.js b/packages/super-editor/src/components/SuperEditor.test.js index bb6f812fb7..c73e4a73e4 100644 --- a/packages/super-editor/src/components/SuperEditor.test.js +++ b/packages/super-editor/src/components/SuperEditor.test.js @@ -237,6 +237,119 @@ describe('SuperEditor.vue', () => { wrapper.unmount(); }); + it('waits for collaboration sync before loading a provided file source for an empty room', async () => { + vi.useFakeTimers(); + + EditorConstructor.loadXmlData.mockResolvedValueOnce([ + '', + { media: true }, + { files: true }, + { fonts: true }, + ]); + + const metaMap = { + has: vi.fn(() => false), + get: vi.fn(() => undefined), + }; + const partsMap = { size: 0 }; + const fragment = { length: 0, observe: vi.fn(), unobserve: vi.fn() }; + const ydoc = { + getMap: vi.fn((name) => (name === 'parts' ? partsMap : metaMap)), + getXmlFragment: vi.fn(() => fragment), + }; + + const provider = { + listeners: {}, + on: vi.fn((event, handler) => { + provider.listeners[event] = handler; + }), + off: vi.fn(), + }; + + const fileSource = new Blob([], { type: DOCX_MIME }); + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-first-client-with-file', + fileSource, + options: { + ydoc, + collaborationProvider: provider, + }, + }, + }); + + await flushPromises(); + + expect(EditorConstructor.loadXmlData).not.toHaveBeenCalled(); + expect(EditorConstructor).not.toHaveBeenCalled(); + + const syncedHandler = provider.on.mock.calls.find(([event]) => event === 'synced')[1]; + syncedHandler(); + await flushPromises(); + + vi.advanceTimersByTime(200); + await flushPromises(); + + expect(EditorConstructor.loadXmlData).toHaveBeenCalledWith(fileSource); + expect(EditorConstructor).toHaveBeenCalledTimes(1); + expect(EditorConstructor.mock.calls[0][0].isNewFile).toBe(true); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('ignores a provided file source when the collaboration room already has content', async () => { + vi.useFakeTimers(); + + const metaMap = { + has: vi.fn(() => false), + get: vi.fn(() => undefined), + }; + const partsMap = { size: 1 }; + const fragment = { length: 5 }; + const ydoc = { + getMap: vi.fn((name) => (name === 'parts' ? partsMap : metaMap)), + getXmlFragment: vi.fn(() => fragment), + }; + + const provider = { + listeners: {}, + on: vi.fn((event, handler) => { + provider.listeners[event] = handler; + }), + off: vi.fn(), + }; + + const fileSource = new Blob([], { type: DOCX_MIME }); + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-existing-room-with-file', + fileSource, + options: { + ydoc, + collaborationProvider: provider, + }, + }, + }); + + await flushPromises(); + + expect(EditorConstructor.loadXmlData).not.toHaveBeenCalled(); + expect(EditorConstructor).not.toHaveBeenCalled(); + + const syncedHandler = provider.on.mock.calls.find(([event]) => event === 'synced')[1]; + syncedHandler(); + await flushPromises(); + + expect(EditorConstructor.loadXmlData).not.toHaveBeenCalled(); + expect(EditorConstructor).toHaveBeenCalledTimes(1); + expect(EditorConstructor.mock.calls[0][0].fragment).toStrictEqual(fragment); + expect(EditorConstructor.mock.calls[0][0].isNewFile).toBe(false); + + wrapper.unmount(); + vi.useRealTimers(); + }); + it('waits for fragment settling and passes the shared fragment to the editor for existing rooms', async () => { vi.useFakeTimers(); diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index 5448af5540..ffd91dc8cc 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -816,21 +816,21 @@ const notifyFileLoadError = () => { console.warn(FILE_LOAD_ERROR_MESSAGE); }; -const initializeData = async () => { - // If we have the file, initialize immediately from file - if (props.fileSource) { - let fileData = await loadNewFileData(); - if (!fileData) { - // TODO: show a visible error to the user (toast removed with naive-ui) - notifyFileLoadError(); - await setDefaultBlankFile(); - fileData = await loadNewFileData(); - } - return initEditor(fileData); +const initEditorFromFileSource = async () => { + let fileData = await loadNewFileData(); + if (!fileData) { + notifyFileLoadError(); + await setDefaultBlankFile(); + fileData = await loadNewFileData(); } + return initEditor(fileData); +}; - // If we are in collaboration mode, wait for sync then initialize - else if (props.options.ydoc && props.options.collaborationProvider) { +const initializeData = async () => { + // Collaboration startup must own room classification before we initialize + // from a local file source. Otherwise the editor can render local content + // while the shared room remains blank until a later async seed. + if (props.options.ydoc && props.options.collaborationProvider) { delete props.options.content; const ydoc = props.options.ydoc; const provider = props.options.collaborationProvider; @@ -885,13 +885,19 @@ const initializeData = async () => { initEditor({}); } else { - // First client — load blank document + // First client — seed the room from the provided file source, or + // fall back to the blank template if no file is provided. props.options.isNewFile = true; delete props.options.fragment; - const fileData = await loadNewFileData(); - if (fileData) initEditor(fileData); + await initEditorFromFileSource(); } }); + return; + } + + // Non-collaborative mode: initialize immediately from the provided file. + if (props.fileSource) { + return initEditorFromFileSource(); } };