diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 9ff9b0b917..5122123b58 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -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'; @@ -3164,7 +3164,11 @@ export class Editor extends EventEmitter { 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(); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index e145f9eb41..9494e490a8 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -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 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} */ -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'); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.d.ts b/packages/super-editor/src/extensions/collaboration/collaboration.d.ts index 529cc0ca39..148304207b 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.d.ts +++ b/packages/super-editor/src/extensions/collaboration/collaboration.d.ts @@ -1 +1,2 @@ export function generateCollaborationData(...args: any[]): any; +export function cancelDebouncedDocxUpdate(editor: any): void; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index ce516d07a7..53b12f9912 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -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 @@ -178,6 +194,8 @@ const initDocumentListener = ({ ydoc, editor }) => { { maxWait: 60000 }, ); + debouncedDocxUpdateByEditor.set(editor, () => debouncedUpdate.cancel()); + const afterTransactionHandler = (transaction) => { const { local } = transaction; @@ -193,6 +211,7 @@ const initDocumentListener = ({ ydoc, editor }) => { return () => { ydoc.off('afterTransaction', afterTransactionHandler); debouncedUpdate.cancel(); + debouncedDocxUpdateByEditor.delete(editor); }; }; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index 5cd6c3ef14..18ac6101e6 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -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: '' }]); + explicitYdoc._maps.metas.store.set('docx', [{ name: 'word/document.xml', content: '' }]); + + 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': '' }), + }; + + const optionsUpdate = updateYdocDocxData(editor); + const explicitUpdate = updateYdocDocxData(editor, explicitYdoc); + + resolveFirstExport({ 'word/document.xml': '' }); + await Promise.all([optionsUpdate, explicitUpdate]); + + expect(optionsYdoc._maps.metas.set).toHaveBeenCalledWith('docx', [ + { name: 'word/document.xml', content: '' }, + ]); + expect(explicitYdoc._maps.metas.set).toHaveBeenCalledWith('docx', [ + { name: 'word/document.xml', content: '' }, + ]); + }); + it('skips transaction when docx content has not changed', async () => { const existingDocx = [ { name: 'word/document.xml', content: '' },