Skip to content
Merged
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
8 changes: 6 additions & 2 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
import { AnnotatorHelpers } from '@helpers/annotator.js';
import { prepareCommentsForExport, prepareCommentsForImport } from '@extensions/comment/comments-helpers.js';
import DocxZipper from '@core/DocxZipper.js';
import { generateCollaborationData } from '@extensions/collaboration/collaboration.js';
import { generateCollaborationData, cancelDebouncedDocxUpdate } from '@extensions/collaboration/collaboration.js';
import { useHighContrastMode } from '../composables/use-high-contrast-mode.js';
import { updateYdocDocxData } from '@extensions/collaboration/collaboration-helpers.js';
import { setImageNodeSelection } from './helpers/setImageNodeSelection.js';
Expand Down Expand Up @@ -3164,7 +3164,11 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.initDefaultStyles();

if (this.options.ydoc && this.options.collaborationProvider) {
updateYdocDocxData(this, this.options.ydoc);
// Cancel any pending debounced docx update — we are about to do a
// fresh export with the new file data. Without cancel, the debounced
// export from the previous transaction cycle could fire redundantly.
cancelDebouncedDocxUpdate(this);
await updateYdocDocxData(this, this.options.ydoc);
this.initializeCollaborationData();
} else {
this.#insertNewFileData();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
// In-flight deduplication: if an export is already running for this (editor, ydoc)
// pair, subsequent calls return the same promise instead of spawning a parallel export.
// Keyed as editor → WeakMap<ydoc, promise> so that calls targeting different ydoc
// instances (e.g. generateCollaborationData's temp ydoc vs editor.options.ydoc) each
// get their own export run.
const inFlightUpdates = new WeakMap();

/**
* Update the Ydoc document data with the latest Docx XML.
*
* Deduplicates concurrent calls for the same (editor, ydoc) pair — if an
* export is already in progress for that exact target, the existing promise is
* returned instead of starting a second expensive exportDocx() call.
*
* @param {Editor} editor The editor instance
* @param {import('yjs').Doc} [ydoc] Target ydoc (defaults to editor.options.ydoc)
* @returns {Promise<void>}
*/
export const updateYdocDocxData = async (editor, ydoc) => {
try {
ydoc = ydoc || editor?.options?.ydoc;
if (!ydoc || ydoc.isDestroyed) return;
if (!editor || editor.isDestroyed) return;
export const updateYdocDocxData = (editor, ydoc) => {
ydoc = ydoc || editor?.options?.ydoc;
if (!ydoc || ydoc.isDestroyed) return Promise.resolve();
if (!editor || editor.isDestroyed) return Promise.resolve();

let ydocMap = inFlightUpdates.get(editor);
if (!ydocMap) {
ydocMap = new WeakMap();
inFlightUpdates.set(editor, ydocMap);
}

const existing = ydocMap.get(ydoc);
if (existing) {
return existing;
}

const promise = _doUpdateYdocDocxData(editor, ydoc).finally(() => {
const map = inFlightUpdates.get(editor);
if (map && map.get(ydoc) === promise) {
map.delete(ydoc);
}
});

ydocMap.set(ydoc, promise);
return promise;
};

const _doUpdateYdocDocxData = async (editor, ydoc) => {
try {
const metaMap = ydoc.getMap('meta');
const docxValue = metaMap.get('docx');

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export function generateCollaborationData(...args: any[]): any;
export function cancelDebouncedDocxUpdate(editor: any): void;
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ const checkDocxChanged = (transaction) => {
return false;
};

// Stores the debounced update cancel function per editor so replaceFile
// can cancel pending debounced exports after doing its own direct export.
const debouncedDocxUpdateByEditor = new WeakMap();

/**
* Cancel any pending debounced updateYdocDocxData call for the given editor.
* Called from replaceFile to prevent stale debounced exports from running
* after the direct export has already been done.
*
* @param {Editor} editor
*/
export const cancelDebouncedDocxUpdate = (editor) => {
const cancel = debouncedDocxUpdateByEditor.get(editor);
if (cancel) cancel();
};

const initDocumentListener = ({ ydoc, editor }) => {
// 30s debounce: the actual document content syncs in real-time via
// y-prosemirror's XmlFragment. This DOCX blob is supplementary data
Expand All @@ -178,6 +194,8 @@ const initDocumentListener = ({ ydoc, editor }) => {
{ maxWait: 60000 },
);

debouncedDocxUpdateByEditor.set(editor, () => debouncedUpdate.cancel());

const afterTransactionHandler = (transaction) => {
const { local } = transaction;

Expand All @@ -193,6 +211,7 @@ const initDocumentListener = ({ ydoc, editor }) => {
return () => {
ydoc.off('afterTransaction', afterTransactionHandler);
debouncedUpdate.cancel();
debouncedDocxUpdateByEditor.delete(editor);
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,39 @@ describe('collaboration helpers', () => {
expect(optionsYdoc._maps.metas.set).not.toHaveBeenCalled();
});

it('updates each target ydoc when concurrent calls use the same editor', async () => {
const optionsYdoc = createYDocStub();
const explicitYdoc = createYDocStub();
optionsYdoc._maps.metas.store.set('docx', [{ name: 'word/document.xml', content: '<old options />' }]);
explicitYdoc._maps.metas.store.set('docx', [{ name: 'word/document.xml', content: '<old explicit />' }]);

let resolveFirstExport;
const firstExport = new Promise((resolve) => {
resolveFirstExport = resolve;
});

const editor = {
options: { ydoc: optionsYdoc, user: { id: 'user-concurrent' } },
exportDocx: vi
.fn()
.mockReturnValueOnce(firstExport)
.mockResolvedValueOnce({ 'word/document.xml': '<new explicit />' }),
};

const optionsUpdate = updateYdocDocxData(editor);
const explicitUpdate = updateYdocDocxData(editor, explicitYdoc);

resolveFirstExport({ 'word/document.xml': '<new options />' });
await Promise.all([optionsUpdate, explicitUpdate]);

expect(optionsYdoc._maps.metas.set).toHaveBeenCalledWith('docx', [
{ name: 'word/document.xml', content: '<new options />' },
]);
expect(explicitYdoc._maps.metas.set).toHaveBeenCalledWith('docx', [
{ name: 'word/document.xml', content: '<new explicit />' },
]);
});

it('skips transaction when docx content has not changed', async () => {
const existingDocx = [
{ name: 'word/document.xml', content: '<same />' },
Expand Down
Loading