Skip to content

Commit 68207b5

Browse files
authored
fix: collaboration upgrade to attach in place and remove remount path (#2550)
* fix: collaboration upgrade to attach in place and remove remount path * fix: harden collaboration upgrade rollback and store sync
1 parent f001315 commit 68207b5

12 files changed

Lines changed: 641 additions & 1249 deletions

File tree

packages/super-editor/src/components/SuperEditor.vue

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -964,51 +964,6 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
964964
presentationEditor: editor.value instanceof PresentationEditor ? editor.value : null,
965965
});
966966
967-
// Upgrade visual-readiness signal: during upgradeToCollaboration, SuperDoc
968-
// threads this callback so it knows when the rebuilt runtime has actually
969-
// painted AND collaboration is ready, not just when editors are created.
970-
// For collaborative remounts the provider is already synced so the
971-
// collaboration extension will emit collaborationReady after a 250ms delay.
972-
// We must wait for BOTH that event AND the first layout paint before
973-
// signalling that the upgrade transition can reveal the new runtime.
974-
const onUpgradeVisualReady = props.options?.onUpgradeVisualReady;
975-
if (typeof onUpgradeVisualReady === 'function') {
976-
const hasCollabProvider = Boolean(props.options?.collaborationProvider);
977-
const isPresentationEditor = editor.value instanceof PresentationEditor;
978-
979-
let collabReady = !hasCollabProvider; // no provider → already satisfied
980-
let layoutReady = !isPresentationEditor; // no layout engine → already satisfied
981-
982-
const tryFire = () => {
983-
if (collabReady && layoutReady) {
984-
nextTick(() => onUpgradeVisualReady());
985-
}
986-
};
987-
988-
if (!collabReady) {
989-
editor.value.once('collaborationReady', () => {
990-
collabReady = true;
991-
tryFire();
992-
});
993-
}
994-
995-
if (!layoutReady) {
996-
const pe = editor.value;
997-
if (pe.getPages().length > 0) {
998-
layoutReady = true;
999-
} else {
1000-
const onFirstLayout = () => {
1001-
pe.off('layoutUpdated', onFirstLayout);
1002-
layoutReady = true;
1003-
tryFire();
1004-
};
1005-
pe.on('layoutUpdated', onFirstLayout);
1006-
}
1007-
}
1008-
1009-
tryFire();
1010-
}
1011-
1012967
// Attach layout-engine specific image selection listeners
1013968
if (editor.value instanceof PresentationEditor) {
1014969
const presentationEditor = editor.value;

packages/super-editor/src/core/Editor.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
import { AnnotatorHelpers } from '@helpers/annotator.js';
4444
import { prepareCommentsForExport, prepareCommentsForImport } from '@extensions/comment/comments-helpers.js';
4545
import DocxZipper from '@core/DocxZipper.js';
46-
import { generateCollaborationData } from '@extensions/collaboration/collaboration.js';
46+
import { generateCollaborationData, cleanupCollaborationSideEffects } from '@extensions/collaboration/collaboration.js';
4747
import { seedPartsFromEditor } from '@extensions/collaboration/part-sync/seed-parts.js';
4848
import { onCollaborationProviderSynced } from './helpers/collaboration-provider-sync.js';
4949
import { useHighContrastMode } from '../composables/use-high-contrast-mode.js';
@@ -1855,6 +1855,88 @@ export class Editor extends EventEmitter<EditorEventMap> {
18551855
this.view?.updateState(this._state);
18561856
}
18571857

1858+
/**
1859+
* Late-attach collaboration to a running editor instance.
1860+
*
1861+
* Updates editor options so the Collaboration, CollaborationCursor, and
1862+
* History extensions produce their collaborative plugins on the next
1863+
* `extensionService.plugins` access, then reconfigures the PM state in place.
1864+
*
1865+
* Prerequisites:
1866+
* - The ydoc must already be seeded with this editor's current state
1867+
* - The provider must already be synced
1868+
* - Editor must be mounted (not headless, not destroyed)
1869+
*
1870+
* @param options.ydoc The Y.Doc to bind
1871+
* @param options.collaborationProvider The synced collaboration provider
1872+
*/
1873+
attachCollaboration({
1874+
ydoc,
1875+
collaborationProvider,
1876+
}: {
1877+
ydoc: YDoc;
1878+
collaborationProvider: NonNullable<EditorOptions['collaborationProvider']>;
1879+
}): void {
1880+
if (this.isDestroyed) {
1881+
throw new Error('[super-editor] Cannot attach collaboration to a destroyed editor');
1882+
}
1883+
if (this.options.ydoc) {
1884+
throw new Error('[super-editor] Editor already has collaboration attached');
1885+
}
1886+
if (this.options.isHeadless) {
1887+
throw new Error('[super-editor] attachCollaboration is not supported in headless mode');
1888+
}
1889+
1890+
// Snapshot mutable state so we can restore on failure.
1891+
const prevProvider = this.options.collaborationProvider;
1892+
const prevShouldLoadComments = this.options.shouldLoadComments;
1893+
const prevCollaborationIsReady = this.options.collaborationIsReady;
1894+
const prevState = this._state;
1895+
1896+
const rollback = () => {
1897+
cleanupCollaborationSideEffects(this);
1898+
this.options.ydoc = undefined;
1899+
this.options.collaborationProvider = prevProvider;
1900+
this.options.shouldLoadComments = prevShouldLoadComments;
1901+
this.options.collaborationIsReady = prevCollaborationIsReady;
1902+
this._state = prevState;
1903+
this.view?.updateState(prevState);
1904+
};
1905+
1906+
// 1. Update options so extensions see ydoc/provider on next plugin generation.
1907+
this.options.ydoc = ydoc;
1908+
this.options.collaborationProvider = collaborationProvider;
1909+
1910+
// 2. Suppress DOCX comment re-import on collaborationReady.
1911+
// In local mode shouldLoadComments was set to true (see setOptions()).
1912+
// Without this, #onCollaborationReady → #initComments() would re-emit
1913+
// commentsLoaded from DOCX data, duplicating the Yjs comment hydration
1914+
// that initCollaborationComments() performs at the SuperDoc layer.
1915+
this.options.shouldLoadComments = false;
1916+
1917+
// 3. Regenerate all plugins and reconfigure PM state.
1918+
// Side effects (Y.js observers, part-sync, initSyncListener) run during
1919+
// the extensionService.plugins getter. On failure, rollback cleans them up.
1920+
let plugins: Plugin[];
1921+
try {
1922+
plugins = [...this.extensionService.plugins];
1923+
} catch (err) {
1924+
rollback();
1925+
throw err;
1926+
}
1927+
1928+
// 4. Reconfigure state with the new plugin set. ProseMirror diffs old vs new.
1929+
// Since the ydoc was seeded from this editor's state, doc content is identical
1930+
// → no content DOM mutations. Selection is preserved by reconfigure().
1931+
try {
1932+
this._state = this.state.reconfigure({ plugins });
1933+
this.view?.updateState(this._state);
1934+
} catch (err) {
1935+
rollback();
1936+
throw err;
1937+
}
1938+
}
1939+
18581940
/**
18591941
* Creates extension service.
18601942
*/

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,48 @@ export class PresentationEditor extends EventEmitter {
841841
return this.#editor;
842842
}
843843

844+
/**
845+
* Late-attach collaboration to the presentation editor.
846+
*
847+
* Updates the provider reference on this instance and RemoteCursorManager,
848+
* then delegates to the backing Editor. The existing `collaborationReady`
849+
* listener (wired in #setupEditorListeners) triggers cursor setup
850+
* automatically when the backing editor emits the event.
851+
*
852+
* @param options.ydoc The Y.Doc already seeded with this editor's state
853+
* @param options.collaborationProvider The synced collaboration provider
854+
*/
855+
attachCollaboration({
856+
ydoc,
857+
collaborationProvider,
858+
}: {
859+
ydoc: Y.Doc;
860+
collaborationProvider: NonNullable<PresentationEditorOptions['collaborationProvider']>;
861+
}): void {
862+
const prevProvider = this.#options.collaborationProvider;
863+
864+
// 1. Update PresentationEditor options so the collaborationReady handler
865+
// check passes (it reads this.#options.collaborationProvider?.awareness).
866+
this.#options.collaborationProvider = collaborationProvider;
867+
868+
// 2. Update RemoteCursorManager's provider reference so setup() reads
869+
// the correct provider when collaborationReady fires.
870+
this.#remoteCursorManager?.setCollaborationProvider(collaborationProvider);
871+
872+
// 3. Delegate to the backing Editor — triggers plugin reconfigure + Y.js observers.
873+
// The collaborationReady event fires asynchronously (setTimeout in initSyncListener).
874+
// The existing listener at handleCollaborationReady calls
875+
// #setupCollaborationCursors() → remoteCursorManager.setup(). No new wiring needed.
876+
try {
877+
this.#editor.attachCollaboration({ ydoc, collaborationProvider });
878+
} catch (err) {
879+
// Editor attach failed and rolled back its own state. Restore ours too.
880+
this.#options.collaborationProvider = prevProvider;
881+
this.#remoteCursorManager?.setCollaborationProvider(prevProvider ?? null);
882+
throw err;
883+
}
884+
}
885+
844886
/**
845887
* Expose the visible host element for renderer-agnostic consumers.
846888
*/

packages/super-editor/src/core/presentation-editor/remote-cursors/RemoteCursorManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ export class RemoteCursorManager {
180180
this.#onCursorsUpdate = callback;
181181
}
182182

183+
/**
184+
* Update the collaboration provider reference. Called during late-attach
185+
* upgrade so `setup()` reads the correct provider when `collaborationReady` fires.
186+
*/
187+
setCollaborationProvider(provider: CollaborationProviderLike): void {
188+
this.#options.collaborationProvider = provider;
189+
}
190+
183191
/**
184192
* Setup awareness event subscriptions for remote cursor tracking.
185193
* Includes scroll listener for virtualization updates.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export function generateCollaborationData(...args: any[]): any;
2+
export function cleanupCollaborationSideEffects(editor: any): void;

packages/super-editor/src/extensions/collaboration/collaboration.js

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,20 @@ export const Collaboration = Extension.create({
150150

151151
addPmPlugins() {
152152
if (!this.editor.options.ydoc) return [];
153+
154+
// Guard against double-initialization. If extensionService.plugins is
155+
// re-accessed after collaboration was already bootstrapped for this editor,
156+
// return the existing sync plugin without re-creating observers or listeners.
157+
if (collaborationCleanupByEditor.has(this.editor)) {
158+
const fragment = this.options.fragment;
159+
if (fragment) {
160+
return [ySyncPlugin(fragment, { onFirstRender: () => {} })];
161+
}
162+
}
163+
153164
this.options.ydoc = this.editor.options.ydoc;
154165

155-
initSyncListener(this.options.ydoc, this.editor, this);
166+
const syncListenerCleanup = initSyncListener(this.options.ydoc, this.editor, this);
156167

157168
const [syncPlugin, fragment] = createSyncPlugin(this.options.ydoc, this.editor);
158169
this.options.fragment = fragment;
@@ -171,6 +182,7 @@ export const Collaboration = Extension.create({
171182
// Store cleanup references in a non-reactive WeakMap (NOT this.options)
172183
// to avoid Vue's deep traverse hitting circular references in Y.js Maps.
173184
const cleanupState = {
185+
syncListenerCleanup,
174186
mediaMap,
175187
mediaMapObserver,
176188
metaMap: null,
@@ -225,22 +237,7 @@ export const Collaboration = Extension.create({
225237
},
226238

227239
onDestroy() {
228-
const cleanup = collaborationCleanupByEditor.get(this.editor);
229-
if (!cleanup) return;
230-
231-
// Clean up Y.js media map observer
232-
cleanup.mediaMap.unobserve(cleanup.mediaMapObserver);
233-
cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver);
234-
235-
// Clean up part-sync publisher/consumer (or pending sync listener)
236-
cleanup.partSyncHandle?.destroy();
237-
cleanup.partSyncPendingCleanup?.();
238-
cleanup.bodySectPrPendingCleanup?.();
239-
if (cleanup.bodySectPrTransactionHandler && typeof this.editor.off === 'function') {
240-
this.editor.off('transaction', cleanup.bodySectPrTransactionHandler);
241-
}
242-
243-
collaborationCleanupByEditor.delete(this.editor);
240+
cleanupCollaborationSideEffects(this.editor);
244241
},
245242

246243
addCommands() {
@@ -257,6 +254,32 @@ export const Collaboration = Extension.create({
257254
},
258255
});
259256

257+
/**
258+
* Tear down collaboration side effects registered during `addPmPlugins()`.
259+
*
260+
* Called by `Collaboration.onDestroy()` during normal teardown and by
261+
* `Editor.attachCollaboration()` rollback if reconfigure fails after
262+
* plugin generation has already created Y.js observers and listeners.
263+
*
264+
* @param {import('../../core/Editor').Editor} editor
265+
*/
266+
export const cleanupCollaborationSideEffects = (editor) => {
267+
const cleanup = collaborationCleanupByEditor.get(editor);
268+
if (!cleanup) return;
269+
270+
cleanup.syncListenerCleanup?.();
271+
cleanup.mediaMap?.unobserve?.(cleanup.mediaMapObserver);
272+
cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver);
273+
cleanup.partSyncHandle?.destroy();
274+
cleanup.partSyncPendingCleanup?.();
275+
cleanup.bodySectPrPendingCleanup?.();
276+
if (cleanup.bodySectPrTransactionHandler && typeof editor.off === 'function') {
277+
editor.off('transaction', cleanup.bodySectPrTransactionHandler);
278+
}
279+
280+
collaborationCleanupByEditor.delete(editor);
281+
};
282+
260283
export const createSyncPlugin = (ydoc, editor) => {
261284
const fragment = ydoc.getXmlFragment('supereditor');
262285
const onFirstRender = () => {
@@ -297,24 +320,43 @@ export const initializeMetaMap = (ydoc, editor) => {
297320
});
298321
};
299322

323+
/**
324+
* Schedule a `collaborationReady` emission once the provider is synced.
325+
*
326+
* Returns a cleanup function that cancels any pending timer or provider
327+
* listener so a rollback in `attachCollaboration()` can prevent stale
328+
* emissions from firing against a rolled-back editor state.
329+
*
330+
* @returns {() => void} cleanup
331+
*/
300332
const initSyncListener = (ydoc, editor, extension) => {
301333
const provider = editor.options.collaborationProvider;
302-
if (!provider) return;
334+
if (!provider) return () => {};
335+
336+
let cancelled = false;
303337

304338
const emit = (synced) => {
339+
if (cancelled) return;
305340
if (synced === false) return;
306341
extension.options.isReady = true;
307342
editor.emit('collaborationReady', { editor, ydoc });
308343
};
309344

310345
if (isCollaborationProviderSynced(provider)) {
311-
setTimeout(() => {
346+
const timerId = setTimeout(() => {
312347
emit();
313348
}, 250);
314-
return;
349+
return () => {
350+
cancelled = true;
351+
clearTimeout(timerId);
352+
};
315353
}
316354

317-
onCollaborationProviderSynced(provider, emit);
355+
const removeProviderListeners = onCollaborationProviderSynced(provider, emit);
356+
return () => {
357+
cancelled = true;
358+
removeProviderListeners();
359+
};
318360
};
319361

320362
export const generateCollaborationData = async (editor) => {

0 commit comments

Comments
 (0)