Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/super-editor/src/components/SuperEditor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
'<docx />',
{ 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();

Expand Down
38 changes: 22 additions & 16 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard collaboration-first init for providers without events

Switching initializeData to prefer the collaboration path means sessions that pass both fileSource and collaboration options now always execute waitForSync, which unconditionally uses provider.on/off. Because CollaborationProvider allows those methods to be absent, an unsynced provider shim (or partial adapter) will now throw or hang before editor creation, and the local file never loads; this is a regression from the previous ordering where file-backed startup bypassed sync waiting. Please gate on the event API (or reuse the existing provider-sync helper) so this path cannot deadlock/crash.

Useful? React with 👍 / 👎.

delete props.options.content;
const ydoc = props.options.ydoc;
const provider = props.options.collaborationProvider;
Expand Down Expand Up @@ -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();
}
};

Expand Down
Loading