From 498b1deffb13a6a0504799c7aeb6d9706f2c3847 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 24 Feb 2026 12:44:33 -0300 Subject: [PATCH 1/2] fix(collaboration): deduplicate updateYdocDocxData calls during replaceFile (SD-1920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaceFile() called updateYdocDocxData() fire-and-forget and did not cancel the debounced export timer. This produced two full exportDocx() runs and two ydoc.transact(docx-update) writes per replaceFile — the second being a redundant Y.js meta-map update that contributed to Liveblocks code-1011 disconnections via tombstone accumulation. Three changes fix it: 1. In-flight deduplication (collaboration-helpers.js) — a WeakMap keyed by editor returns the existing promise when a concurrent call arrives instead of spawning a parallel export. 2. Cancel debounced timer (collaboration.js) — new cancelDebouncedDocxUpdate() export clears the pending 30s/60s debounce before the direct export runs. 3. Await the export (Editor.ts) — replaceFile now awaits updateYdocDocxData() so the export finishes before initializeCollaborationData() overwrites meta. --- packages/super-editor/src/core/Editor.ts | 8 +++-- .../collaboration/collaboration-helpers.js | 34 ++++++++++++++++--- .../collaboration/collaboration.d.ts | 1 + .../extensions/collaboration/collaboration.js | 19 +++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index ae4e4dcce1..5309c3b8d4 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..1abb856438 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -1,15 +1,39 @@ +// In-flight deduplication: if an export is already running for this editor, +// subsequent calls return the same promise instead of spawning a parallel export. +const inFlightUpdates = new WeakMap(); + /** * Update the Ydoc document data with the latest Docx XML. * + * Deduplicates concurrent calls for the same editor — if an export is already + * in progress, the existing promise is returned instead of starting a second + * expensive exportDocx() call. + * * @param {Editor} editor The editor instance * @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(); + + const existing = inFlightUpdates.get(editor); + if (existing) { + return existing; + } + + const promise = _doUpdateYdocDocxData(editor, ydoc).finally(() => { + if (inFlightUpdates.get(editor) === promise) { + inFlightUpdates.delete(editor); + } + }); + inFlightUpdates.set(editor, 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); }; }; From 20b27c14cdb7dafd3937ccbc29a7823c275e6719 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 26 Feb 2026 16:06:11 -0800 Subject: [PATCH 2/2] fix(collaboration): scope in-flight deduplication to (editor, ydoc) pair --- .../collaboration/collaboration-helpers.js | 29 +++++++++++----- .../collaboration/collaboration.test.js | 33 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index 1abb856438..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,19 @@ -// In-flight deduplication: if an export is already running for this editor, -// subsequent calls return the same promise instead of spawning a parallel export. +// 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 — if an export is already - * in progress, the existing promise is returned instead of starting a second - * expensive exportDocx() call. + * 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 = (editor, ydoc) => { @@ -17,18 +21,25 @@ export const updateYdocDocxData = (editor, ydoc) => { if (!ydoc || ydoc.isDestroyed) return Promise.resolve(); if (!editor || editor.isDestroyed) return Promise.resolve(); - const existing = inFlightUpdates.get(editor); + 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(() => { - if (inFlightUpdates.get(editor) === promise) { - inFlightUpdates.delete(editor); + const map = inFlightUpdates.get(editor); + if (map && map.get(ydoc) === promise) { + map.delete(ydoc); } }); - inFlightUpdates.set(editor, promise); + ydocMap.set(ydoc, promise); return promise; }; 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: '' },