diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index b6b904c144..117caeef0d 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -964,51 +964,6 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = presentationEditor: editor.value instanceof PresentationEditor ? editor.value : null, }); - // Upgrade visual-readiness signal: during upgradeToCollaboration, SuperDoc - // threads this callback so it knows when the rebuilt runtime has actually - // painted AND collaboration is ready, not just when editors are created. - // For collaborative remounts the provider is already synced so the - // collaboration extension will emit collaborationReady after a 250ms delay. - // We must wait for BOTH that event AND the first layout paint before - // signalling that the upgrade transition can reveal the new runtime. - const onUpgradeVisualReady = props.options?.onUpgradeVisualReady; - if (typeof onUpgradeVisualReady === 'function') { - const hasCollabProvider = Boolean(props.options?.collaborationProvider); - const isPresentationEditor = editor.value instanceof PresentationEditor; - - let collabReady = !hasCollabProvider; // no provider → already satisfied - let layoutReady = !isPresentationEditor; // no layout engine → already satisfied - - const tryFire = () => { - if (collabReady && layoutReady) { - nextTick(() => onUpgradeVisualReady()); - } - }; - - if (!collabReady) { - editor.value.once('collaborationReady', () => { - collabReady = true; - tryFire(); - }); - } - - if (!layoutReady) { - const pe = editor.value; - if (pe.getPages().length > 0) { - layoutReady = true; - } else { - const onFirstLayout = () => { - pe.off('layoutUpdated', onFirstLayout); - layoutReady = true; - tryFire(); - }; - pe.on('layoutUpdated', onFirstLayout); - } - } - - tryFire(); - } - // Attach layout-engine specific image selection listeners if (editor.value instanceof PresentationEditor) { const presentationEditor = editor.value; diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 23ce430e04..b8f11e0439 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -43,7 +43,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, cleanupCollaborationSideEffects } from '@extensions/collaboration/collaboration.js'; import { seedPartsFromEditor } from '@extensions/collaboration/part-sync/seed-parts.js'; import { onCollaborationProviderSynced } from './helpers/collaboration-provider-sync.js'; import { useHighContrastMode } from '../composables/use-high-contrast-mode.js'; @@ -1855,6 +1855,88 @@ export class Editor extends EventEmitter { this.view?.updateState(this._state); } + /** + * Late-attach collaboration to a running editor instance. + * + * Updates editor options so the Collaboration, CollaborationCursor, and + * History extensions produce their collaborative plugins on the next + * `extensionService.plugins` access, then reconfigures the PM state in place. + * + * Prerequisites: + * - The ydoc must already be seeded with this editor's current state + * - The provider must already be synced + * - Editor must be mounted (not headless, not destroyed) + * + * @param options.ydoc The Y.Doc to bind + * @param options.collaborationProvider The synced collaboration provider + */ + attachCollaboration({ + ydoc, + collaborationProvider, + }: { + ydoc: YDoc; + collaborationProvider: NonNullable; + }): void { + if (this.isDestroyed) { + throw new Error('[super-editor] Cannot attach collaboration to a destroyed editor'); + } + if (this.options.ydoc) { + throw new Error('[super-editor] Editor already has collaboration attached'); + } + if (this.options.isHeadless) { + throw new Error('[super-editor] attachCollaboration is not supported in headless mode'); + } + + // Snapshot mutable state so we can restore on failure. + const prevProvider = this.options.collaborationProvider; + const prevShouldLoadComments = this.options.shouldLoadComments; + const prevCollaborationIsReady = this.options.collaborationIsReady; + const prevState = this._state; + + const rollback = () => { + cleanupCollaborationSideEffects(this); + this.options.ydoc = undefined; + this.options.collaborationProvider = prevProvider; + this.options.shouldLoadComments = prevShouldLoadComments; + this.options.collaborationIsReady = prevCollaborationIsReady; + this._state = prevState; + this.view?.updateState(prevState); + }; + + // 1. Update options so extensions see ydoc/provider on next plugin generation. + this.options.ydoc = ydoc; + this.options.collaborationProvider = collaborationProvider; + + // 2. Suppress DOCX comment re-import on collaborationReady. + // In local mode shouldLoadComments was set to true (see setOptions()). + // Without this, #onCollaborationReady → #initComments() would re-emit + // commentsLoaded from DOCX data, duplicating the Yjs comment hydration + // that initCollaborationComments() performs at the SuperDoc layer. + this.options.shouldLoadComments = false; + + // 3. Regenerate all plugins and reconfigure PM state. + // Side effects (Y.js observers, part-sync, initSyncListener) run during + // the extensionService.plugins getter. On failure, rollback cleans them up. + let plugins: Plugin[]; + try { + plugins = [...this.extensionService.plugins]; + } catch (err) { + rollback(); + throw err; + } + + // 4. Reconfigure state with the new plugin set. ProseMirror diffs old vs new. + // Since the ydoc was seeded from this editor's state, doc content is identical + // → no content DOM mutations. Selection is preserved by reconfigure(). + try { + this._state = this.state.reconfigure({ plugins }); + this.view?.updateState(this._state); + } catch (err) { + rollback(); + throw err; + } + } + /** * Creates extension service. */ diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 10239c034a..93046bd638 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -841,6 +841,48 @@ export class PresentationEditor extends EventEmitter { return this.#editor; } + /** + * Late-attach collaboration to the presentation editor. + * + * Updates the provider reference on this instance and RemoteCursorManager, + * then delegates to the backing Editor. The existing `collaborationReady` + * listener (wired in #setupEditorListeners) triggers cursor setup + * automatically when the backing editor emits the event. + * + * @param options.ydoc The Y.Doc already seeded with this editor's state + * @param options.collaborationProvider The synced collaboration provider + */ + attachCollaboration({ + ydoc, + collaborationProvider, + }: { + ydoc: Y.Doc; + collaborationProvider: NonNullable; + }): void { + const prevProvider = this.#options.collaborationProvider; + + // 1. Update PresentationEditor options so the collaborationReady handler + // check passes (it reads this.#options.collaborationProvider?.awareness). + this.#options.collaborationProvider = collaborationProvider; + + // 2. Update RemoteCursorManager's provider reference so setup() reads + // the correct provider when collaborationReady fires. + this.#remoteCursorManager?.setCollaborationProvider(collaborationProvider); + + // 3. Delegate to the backing Editor — triggers plugin reconfigure + Y.js observers. + // The collaborationReady event fires asynchronously (setTimeout in initSyncListener). + // The existing listener at handleCollaborationReady calls + // #setupCollaborationCursors() → remoteCursorManager.setup(). No new wiring needed. + try { + this.#editor.attachCollaboration({ ydoc, collaborationProvider }); + } catch (err) { + // Editor attach failed and rolled back its own state. Restore ours too. + this.#options.collaborationProvider = prevProvider; + this.#remoteCursorManager?.setCollaborationProvider(prevProvider ?? null); + throw err; + } + } + /** * Expose the visible host element for renderer-agnostic consumers. */ diff --git a/packages/super-editor/src/core/presentation-editor/remote-cursors/RemoteCursorManager.ts b/packages/super-editor/src/core/presentation-editor/remote-cursors/RemoteCursorManager.ts index 229045e76f..43ca9decf9 100644 --- a/packages/super-editor/src/core/presentation-editor/remote-cursors/RemoteCursorManager.ts +++ b/packages/super-editor/src/core/presentation-editor/remote-cursors/RemoteCursorManager.ts @@ -180,6 +180,14 @@ export class RemoteCursorManager { this.#onCursorsUpdate = callback; } + /** + * Update the collaboration provider reference. Called during late-attach + * upgrade so `setup()` reads the correct provider when `collaborationReady` fires. + */ + setCollaborationProvider(provider: CollaborationProviderLike): void { + this.#options.collaborationProvider = provider; + } + /** * Setup awareness event subscriptions for remote cursor tracking. * Includes scroll listener for virtualization updates. diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.d.ts b/packages/super-editor/src/extensions/collaboration/collaboration.d.ts index 529cc0ca39..01a4295cc0 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 cleanupCollaborationSideEffects(editor: any): void; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index f9799f7e99..4cd0a1b4eb 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -150,9 +150,20 @@ export const Collaboration = Extension.create({ addPmPlugins() { if (!this.editor.options.ydoc) return []; + + // Guard against double-initialization. If extensionService.plugins is + // re-accessed after collaboration was already bootstrapped for this editor, + // return the existing sync plugin without re-creating observers or listeners. + if (collaborationCleanupByEditor.has(this.editor)) { + const fragment = this.options.fragment; + if (fragment) { + return [ySyncPlugin(fragment, { onFirstRender: () => {} })]; + } + } + this.options.ydoc = this.editor.options.ydoc; - initSyncListener(this.options.ydoc, this.editor, this); + const syncListenerCleanup = initSyncListener(this.options.ydoc, this.editor, this); const [syncPlugin, fragment] = createSyncPlugin(this.options.ydoc, this.editor); this.options.fragment = fragment; @@ -171,6 +182,7 @@ export const Collaboration = Extension.create({ // Store cleanup references in a non-reactive WeakMap (NOT this.options) // to avoid Vue's deep traverse hitting circular references in Y.js Maps. const cleanupState = { + syncListenerCleanup, mediaMap, mediaMapObserver, metaMap: null, @@ -225,22 +237,7 @@ export const Collaboration = Extension.create({ }, onDestroy() { - const cleanup = collaborationCleanupByEditor.get(this.editor); - if (!cleanup) return; - - // Clean up Y.js media map observer - cleanup.mediaMap.unobserve(cleanup.mediaMapObserver); - cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver); - - // Clean up part-sync publisher/consumer (or pending sync listener) - cleanup.partSyncHandle?.destroy(); - cleanup.partSyncPendingCleanup?.(); - cleanup.bodySectPrPendingCleanup?.(); - if (cleanup.bodySectPrTransactionHandler && typeof this.editor.off === 'function') { - this.editor.off('transaction', cleanup.bodySectPrTransactionHandler); - } - - collaborationCleanupByEditor.delete(this.editor); + cleanupCollaborationSideEffects(this.editor); }, addCommands() { @@ -257,6 +254,32 @@ export const Collaboration = Extension.create({ }, }); +/** + * Tear down collaboration side effects registered during `addPmPlugins()`. + * + * Called by `Collaboration.onDestroy()` during normal teardown and by + * `Editor.attachCollaboration()` rollback if reconfigure fails after + * plugin generation has already created Y.js observers and listeners. + * + * @param {import('../../core/Editor').Editor} editor + */ +export const cleanupCollaborationSideEffects = (editor) => { + const cleanup = collaborationCleanupByEditor.get(editor); + if (!cleanup) return; + + cleanup.syncListenerCleanup?.(); + cleanup.mediaMap?.unobserve?.(cleanup.mediaMapObserver); + cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver); + cleanup.partSyncHandle?.destroy(); + cleanup.partSyncPendingCleanup?.(); + cleanup.bodySectPrPendingCleanup?.(); + if (cleanup.bodySectPrTransactionHandler && typeof editor.off === 'function') { + editor.off('transaction', cleanup.bodySectPrTransactionHandler); + } + + collaborationCleanupByEditor.delete(editor); +}; + export const createSyncPlugin = (ydoc, editor) => { const fragment = ydoc.getXmlFragment('supereditor'); const onFirstRender = () => { @@ -297,24 +320,43 @@ export const initializeMetaMap = (ydoc, editor) => { }); }; +/** + * Schedule a `collaborationReady` emission once the provider is synced. + * + * Returns a cleanup function that cancels any pending timer or provider + * listener so a rollback in `attachCollaboration()` can prevent stale + * emissions from firing against a rolled-back editor state. + * + * @returns {() => void} cleanup + */ const initSyncListener = (ydoc, editor, extension) => { const provider = editor.options.collaborationProvider; - if (!provider) return; + if (!provider) return () => {}; + + let cancelled = false; const emit = (synced) => { + if (cancelled) return; if (synced === false) return; extension.options.isReady = true; editor.emit('collaborationReady', { editor, ydoc }); }; if (isCollaborationProviderSynced(provider)) { - setTimeout(() => { + const timerId = setTimeout(() => { emit(); }, 250); - return; + return () => { + cancelled = true; + clearTimeout(timerId); + }; } - onCollaborationProviderSynced(provider, emit); + const removeProviderListeners = onCollaborationProviderSynced(provider, emit); + return () => { + cancelled = true; + removeProviderListeners(); + }; }; export const generateCollaborationData = async (editor) => { diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index c0ed79dfd4..7ec41416a1 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -32,8 +32,14 @@ import * as Yjs from 'yjs'; import * as CollaborationModule from './collaboration.js'; -const { Collaboration, CollaborationPluginKey, createSyncPlugin, initializeMetaMap, generateCollaborationData } = - CollaborationModule; +const { + Collaboration, + CollaborationPluginKey, + createSyncPlugin, + initializeMetaMap, + generateCollaborationData, + cleanupCollaborationSideEffects, +} = CollaborationModule; const createYMap = (initial = {}) => { const store = new Map(Object.entries(initial)); @@ -812,4 +818,64 @@ describe('collaboration extension', () => { expect(ydoc._maps.media.set).toHaveBeenCalledWith('word/media/img.png', new Uint8Array([5])); }); }); + + describe('initSyncListener cleanup (rollback safety)', () => { + it('cancels the pending 250ms timer when cleanup runs before it fires', () => { + vi.useFakeTimers(); + try { + const ydoc = createYDocStub(); + // Provider already synced → initSyncListener takes the setTimeout path. + const provider = { synced: true, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { isHeadless: false, ydoc, collaborationProvider: provider }, + storage: { image: { media: {} } }, + emit: vi.fn(), + view: { state: { doc: {} }, dispatch: vi.fn() }, + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + + // Cleanup before the 250ms timer fires (simulates rollback). + cleanupCollaborationSideEffects(editor); + + vi.advanceTimersByTime(300); + + // collaborationReady should NOT have been emitted. + expect(editor.emit).not.toHaveBeenCalledWith('collaborationReady', expect.anything()); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels provider sync listeners when cleanup runs before sync', () => { + const ydoc = createYDocStub(); + // Provider not synced → initSyncListener registers event listeners. + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { isHeadless: false, ydoc, collaborationProvider: provider }, + storage: { image: { media: {} } }, + emit: vi.fn(), + view: { state: { doc: {} }, dispatch: vi.fn() }, + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + + // Cleanup before provider sync (simulates rollback). + cleanupCollaborationSideEffects(editor); + + // Simulate late provider sync — should be no-op. + const syncedHandlers = provider.on.mock.calls + .filter(([event]) => event === 'synced') + .map(([, handler]) => handler); + syncedHandlers.forEach((handler) => handler()); + + const syncHandlers = provider.on.mock.calls.filter(([event]) => event === 'sync').map(([, handler]) => handler); + syncHandlers.forEach((handler) => handler(true)); + + // collaborationReady should NOT have been emitted. + expect(editor.emit).not.toHaveBeenCalledWith('collaborationReady', expect.anything()); + }); + }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 49bfb186b4..7d1aadd7de 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -711,13 +711,6 @@ const editorOptions = (doc) => { licenseKey: proxy.$superdoc.config.telemetry?.licenseKey, } : null, - // Upgrade transition: suppress skeleton and thread visual-ready callback - ...(proxy.$superdoc._upgradeVisualReadyCallback - ? { - suppressSkeletonLoader: true, - onUpgradeVisualReady: proxy.$superdoc._upgradeVisualReadyCallback, - } - : {}), }; return options; diff --git a/packages/superdoc/src/assets/styles/elements/superdoc.css b/packages/superdoc/src/assets/styles/elements/superdoc.css index 3f10847629..97383be4e0 100644 --- a/packages/superdoc/src/assets/styles/elements/superdoc.css +++ b/packages/superdoc/src/assets/styles/elements/superdoc.css @@ -17,23 +17,3 @@ .superdoc--web-layout .superdoc__sub-document { width: 100%; } - -/* --- Upgrade transition (upgradeToCollaboration) --- */ - -.sd-upgrade-overlay { - position: absolute; - inset: 0; - z-index: 9999; - pointer-events: none; - overflow: hidden; -} - -.sd-upgrade-overlay [contenteditable] { - pointer-events: none; - user-select: none; -} - -/* visibility: hidden (not display: none) so layout engine can still measure */ -.sd-upgrade-hidden { - visibility: hidden !important; -} diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index dbbb7c88c8..2b40f6914d 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -2,7 +2,7 @@ import '../style.css'; import { EventEmitter } from 'eventemitter3'; import { v4 as uuidv4 } from 'uuid'; -import { markRaw } from 'vue'; +import { markRaw, toRaw } from 'vue'; import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; import { DOCX, PDF, HTML } from '@superdoc/common'; @@ -14,11 +14,6 @@ import { createDownload, cleanName } from './helpers/export.js'; import { initSuperdocYdoc, initCollaborationComments, makeDocumentsCollaborative } from './collaboration/helpers.js'; import { setupAwarenessHandler } from './collaboration/collaboration.js'; import { overwriteRoomComments, overwriteRoomLockState } from './collaboration/room-overwrite.js'; -import { - createUpgradeSnapshot, - revealNewRuntime, - teardownUpgradeTransition, -} from './collaboration/upgrade-transition.js'; import { normalizeDocumentEntry } from './helpers/file.js'; import { isAllowed } from './collaboration/permissions.js'; import { Whiteboard } from './whiteboard/Whiteboard'; @@ -94,12 +89,8 @@ export class SuperDoc extends EventEmitter { /** @type {HTMLDivElement | null} */ #mountWrapper = null; - /** @type {HTMLDivElement | null} — snapshot overlay during upgrade transition */ - #upgradeOverlay = null; - /** @type {SurfaceManager} */ #surfaceManager; - /** @type {string} */ version; @@ -349,19 +340,16 @@ export class SuperDoc extends EventEmitter { this.#initWhiteboard(); this.#addToolbar(); - // --- Start the rebuildable runtime --- + // Mount the runtime once the outer shell is ready. this.#startRuntime(); } // --------------------------------------------------------------------------- - // Rebuildable runtime lifecycle + // Runtime mount lifecycle // --------------------------------------------------------------------------- /** - * Start (or restart) the Vue app, stores, and editor mount. - * - * Called once during initial construction and again after - * `upgradeToCollaboration()` tears down the previous runtime. + * Mount the Vue app, stores, and editor runtime. */ #startRuntime() { this.#initVueApp(); @@ -369,56 +357,6 @@ export class SuperDoc extends EventEmitter { this.app.mount(this.#mountWrapper); } - /** - * Tear down the current Vue app and stores without destroying the - * outer SuperDoc shell (listeners, toolbar, whiteboard, mount wrapper). - * - * Must be paired with a subsequent `#startRuntime()` call. - */ - #stopRuntime() { - // Settle all active surfaces before Vue unmounts their components - this.#surfaceManager.settleAll({ status: 'closed', reason: 'runtime-restart' }); - - // Detach toolbar from current editor before unmount - if (this.toolbar && this.activeEditor) { - this.toolbar.setActiveEditor(null); - } - this.activeEditor = null; - - // Close comments list if open (will be reopened after remount if needed) - this.removeCommentsList(); - - // Tear down Vue app and stores - if (this.app) { - this.superdocStore.reset(); - this.app.unmount(); - delete this.app.config.globalProperties.$config; - delete this.app.config.globalProperties.$superdoc; - } - } - - // --------------------------------------------------------------------------- - // Upgrade transition (snapshot-overlay-hide-reveal) - // DOM logic lives in ./collaboration/upgrade-transition.js; these thin - // wrappers manage the #upgradeOverlay instance state. - // --------------------------------------------------------------------------- - - #createUpgradeSnapshot() { - const result = createUpgradeSnapshot(this.element, this.#mountWrapper); - this.#upgradeOverlay = result?.overlay ?? null; - return result; - } - - #revealNewRuntime(snapshot) { - revealNewRuntime(this.#mountWrapper, this.#upgradeOverlay, snapshot); - this.#upgradeOverlay = null; - } - - #teardownUpgradeTransition(snapshot) { - teardownUpgradeTransition(this.#mountWrapper, this.#upgradeOverlay, snapshot); - this.#upgradeOverlay = null; - } - #initWhiteboard() { const config = this.config.modules?.whiteboard ?? {}; const enabled = config.enabled ?? false; @@ -622,10 +560,10 @@ export class SuperDoc extends EventEmitter { return this.config.documents; } - // Flag this superdoc as collaborative (legacy path sets this directly) + // Flag this superdoc as collaborative. this.isCollaborative = true; - // Fallback: internal provider creation (legacy mode) + // Fallback: internal provider creation. // Start a socket for all documents and general metaMap for this SuperDoc if (collaborationModuleConfig.providerType === 'hocuspocus') { this.config.socket = new HocuspocusProviderWebsocket({ @@ -737,7 +675,7 @@ export class SuperDoc extends EventEmitter { /** * Upgrade a local SuperDoc instance into collaboration by overwriting * the supplied room with the current local document and comment state, - * then remounting the runtime in collaboration mode. + * then attaching collaboration to the live editor instance in place. * * This is a **destructive promotion**: the target room is authoritatively * overwritten with the caller's current local state. It is NOT the API @@ -753,148 +691,185 @@ export class SuperDoc extends EventEmitter { */ async upgradeToCollaboration({ ydoc, provider }) { this.#validateUpgradePrerequisites({ ydoc, provider }); - this.#isUpgrading = true; + try { const sourceEditor = this.#resolveSourceEditor(); await this.#waitForProviderSync(provider); this.#assertNotDestroyed(); - // --- Seed the room authoritatively (while local runtime is still alive) --- + // --- Seed the room authoritatively (while editor is still local) --- seedEditorStateToYDoc(sourceEditor, ydoc); overwriteRoomComments(ydoc, this.commentsStore.commentsList); overwriteRoomLockState(ydoc, { isLocked: this.isLocked, lockedBy: this.lockedBy }); - // Capture state for rollback and visual continuity - const rollbackJson = sourceEditor.getJSON(); - const rollbackConvertedXml = JSON.parse(JSON.stringify(sourceEditor.converter?.convertedXml ?? {})); - const rollbackMediaFiles = { ...(sourceEditor.options?.mediaFiles ?? {}) }; - const hadCommentsList = Boolean(this.commentsList); - - // --- Snapshot live DOM before the point of no return --- - const snapshot = this.#createUpgradeSnapshot(); - - // --- Point of no return: teardown + reconfigure + rebuild --- - this.#stopRuntime(); + // --- Attach collaboration config (awareness, flags, config.documents) --- this.config.modules.collaboration = { ydoc, provider }; this.#attachExternalCollaboration(ydoc, provider); - try { - await this.#startRuntimeAndWaitForVisualReady(snapshot, { - timeoutMs: 30_000, - timeoutMessage: 'SuperDoc: collaborative runtime did not become visually ready within 30 s', - }); - } catch (remountError) { - if (this.#destroyed) { - this.#teardownUpgradeTransition(snapshot); - throw remountError; - } - - // --- Rollback: stop the failed collaborative runtime, rebuild local --- - this.#stopRuntime(); - this.#detachCollaboration(); - this.config.jsonOverride = rollbackJson; - - try { - await this.#startRuntimeAndWaitForVisualReady(snapshot, { - timeoutMs: 10_000, - timeoutMessage: 'SuperDoc: rollback runtime did not become visually ready within 10 s', - }); - } catch { - this.#teardownUpgradeTransition(snapshot); - throw remountError; - } + // --- Update live store documents in place (no Vue unmount) --- + this.#setStoreDocumentCollaboration(ydoc, provider); - this.config.jsonOverride = null; - this.#restoreRollbackDocumentState(rollbackConvertedXml, rollbackMediaFiles); - this.#revealNewRuntime(snapshot); - - if (hadCommentsList) this.addCommentsList(); - throw remountError; + // --- Hot-swap collaboration into the live editor --- + const editorInstance = this.#resolveUpgradeTarget(); + try { + editorInstance.attachCollaboration({ ydoc, collaborationProvider: provider }); + } catch (attachError) { + // Rollback: undo config/store/awareness mutations. + // The editor rolled back its own options and cleaned up side effects. + this.#rollbackCollaborationAttach(); + throw attachError; } - // --- Success: reveal the new collaborative runtime --- - this.#revealNewRuntime(snapshot); - - if (hadCommentsList) this.addCommentsList(); + // --- Wait for collaborationReady so cursors and UI are fully wired --- + // The collaborationReady event fires asynchronously after attachCollaboration + // returns (via initSyncListener → setTimeout). The returned promise only + // resolves once the editor is fully collaborative. + // + // If the wait times out or is aborted by destroy(), we do NOT rollback. + // The attach succeeded — the editor IS collaborative. The timeout only + // means secondary setup (cursors, presence) is delayed. Rejecting or + // rolling back would strand the instance in a worse state. + await this.#waitForCollaborationReady(editorInstance); + + // If destroy() fired during the readiness wait, bail out before + // registering any new listeners/observers against the dead instance. + if (this.#destroyed) return; + + // --- Wire collaboration comments (from Yjs, not DOCX re-import) --- + initCollaborationComments(this); } finally { this.#abortUpgrade = null; this.#isUpgrading = false; - this._upgradeVisualReadyCallback = null; } } /** - * Start a new runtime behind the snapshot overlay and wait for the - * visual-ready callback from the editor layer. Rejects on timeout - * or if `destroy()` fires `#abortUpgrade`. - * - * @param {{ overlay: HTMLDivElement } | null} snapshot - * @param {{ timeoutMs: number, timeoutMessage: string }} options - * @returns {Promise} + * Throw if the instance has been destroyed. Used as a checkpoint after + * async waits inside upgradeToCollaboration(). */ - async #startRuntimeAndWaitForVisualReady(snapshot, { timeoutMs, timeoutMessage }) { - let timer; - try { - const promise = new Promise((resolve, reject) => { - timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - this._upgradeVisualReadyCallback = () => { - clearTimeout(timer); - resolve(undefined); - }; - this.#abortUpgrade = () => { - clearTimeout(timer); - reject(new Error('SuperDoc: instance was destroyed during upgrade')); - }; - }); + #assertNotDestroyed() { + if (this.#destroyed) { + throw new Error('SuperDoc: instance was destroyed during upgrade'); + } + } - this.#startRuntime(); + // --------------------------------------------------------------------------- + // Late-upgrade helpers + // --------------------------------------------------------------------------- - if (snapshot) { - const newEl = this.#mountWrapper?.querySelector('.superdoc'); - if (newEl) newEl.classList.add('sd-upgrade-hidden'); + /** + * Set ydoc/provider on live store document composables. + * Each composable uses shallowRef for these fields (use-document.js:28-29), + * so we assign to `.value` directly. Vue's reactive proxy auto-unwraps + * shallowRefs on property access, so we must use `toRaw()` to reach the + * underlying ref objects. + * + * @param {import('yjs').Doc | null} ydoc + * @param {import('./types').CollaborationProvider | null} provider + */ + #setStoreDocumentCollaboration(ydoc, provider) { + const storeDocs = this.superdocStore?.documents; + if (!Array.isArray(storeDocs)) return; + for (const doc of storeDocs) { + const raw = toRaw(doc); + if (raw.ydoc && typeof raw.ydoc === 'object' && 'value' in raw.ydoc) { + raw.ydoc.value = ydoc; + } + if (raw.provider && typeof raw.provider === 'object' && 'value' in raw.provider) { + raw.provider.value = provider; } - - await promise; - } catch (error) { - clearTimeout(timer); - this._upgradeVisualReadyCallback = null; - throw error; } } /** - * Throw if the instance has been destroyed. Used as a checkpoint after - * async waits inside upgradeToCollaboration(). + * Resolve the editor instance that supports `attachCollaboration`. + * Prefers PresentationEditor (has cursor/layout support); falls back to raw Editor. + * + * @returns {import('@superdoc/super-editor').PresentationEditor | import('@superdoc/super-editor').Editor} */ - #assertNotDestroyed() { - if (this.#destroyed) { - throw new Error('SuperDoc: instance was destroyed during upgrade'); + #resolveUpgradeTarget() { + const storeDocs = this.superdocStore?.documents; + if (!storeDocs?.length) { + throw new Error('SuperDoc: no store documents available for upgrade'); + } + const target = storeDocs[0].getPresentationEditor?.() || storeDocs[0].getEditor?.(); + if (!target?.attachCollaboration) { + throw new Error('SuperDoc: editor does not support attachCollaboration'); } + return target; } /** - * Restore non-PM document state (parts XML, media files) on the rollback - * editor. The PM JSON is restored via `jsonOverride`, but converter parts - * and media must be patched explicitly since they were lost during - * re-import from the original document source. + * Undo config/store/awareness mutations if `editor.attachCollaboration()` fails. + * The editor itself is still in local mode (the throw happened before or during + * reconfigure), so we only need to undo the SuperDoc-layer changes. + */ + #rollbackCollaborationAttach() { + this.#detachCollaboration(); + this.#setStoreDocumentCollaboration(null, null); + } + + /** + * Wait for the backing editor to emit `collaborationReady` after a live + * attach. Resolves immediately if the editor has already fired the event. + * + * This wait is **non-fatal**: if it times out or is aborted by `destroy()`, + * the promise still resolves (not rejects). The attach already succeeded, + * so the editor IS collaborative. A timeout only means secondary setup + * (cursors, presence) is delayed — rolling back would be worse. * - * @param {Record} convertedXml - * @param {Record} mediaFiles + * @param {import('@superdoc/super-editor').Editor | import('@superdoc/super-editor').PresentationEditor} editorInstance + * @returns {Promise} */ - #restoreRollbackDocumentState(convertedXml, mediaFiles) { - try { - const editor = this.#resolveSourceEditor(); - if (editor.converter && convertedXml) { - editor.converter.convertedXml = convertedXml; - } - if (mediaFiles) { - editor.options.mediaFiles = mediaFiles; - } - } catch { - // Best-effort — editor may not be resolvable in edge cases + #waitForCollaborationReady(editorInstance) { + const TIMEOUT_MS = 10_000; + + // PresentationEditor wraps Editor; get the underlying editor for event listening. + const editor = editorInstance.editor ?? editorInstance; + + // If collaborationReady already fired (options flag set by collaboration extension) + if (editor.options?.collaborationIsReady) { + return Promise.resolve(); } + + return new Promise((resolve) => { + let settled = false; + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (typeof editor.off === 'function') editor.off('collaborationReady', onReady); + }; + + const timer = setTimeout(() => { + cleanup(); + console.warn( + '[SuperDoc] collaborationReady did not fire within 10 s after collaboration attach. Continuing — collaboration is active but cursor/presence setup may be delayed.', + ); + resolve(undefined); + }, TIMEOUT_MS); + + const onReady = () => { + cleanup(); + resolve(undefined); + }; + + // Allow destroy() to abort this wait immediately. + this.#abortUpgrade = () => { + cleanup(); + resolve(undefined); + }; + + if (typeof editor.on === 'function') { + editor.on('collaborationReady', onReady); + } else { + cleanup(); + resolve(undefined); + } + }); } /** @@ -1729,18 +1704,10 @@ export class SuperDoc extends EventEmitter { this.#abortUpgrade = null; } - // Clean up any in-flight upgrade transition overlay - if (this.#upgradeOverlay) { - this.#upgradeOverlay.remove(); - this.#upgradeOverlay = null; - } - this._upgradeVisualReadyCallback = null; - // Settle all active surfaces before Vue unmount if (this.#surfaceManager) { this.#surfaceManager.destroy(); } - // Unmount the app FIRST so editors are destroyed — this triggers each // extension's onDestroy() which cancels debounced Y.js writes and // unobserves Y.js maps. Only then is it safe to destroy the ydoc/provider. diff --git a/packages/superdoc/src/core/collaboration/upgrade-transition.js b/packages/superdoc/src/core/collaboration/upgrade-transition.js deleted file mode 100644 index d3c08f25ee..0000000000 --- a/packages/superdoc/src/core/collaboration/upgrade-transition.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * DOM transition helpers for the SuperDoc collaboration upgrade. - * - * During `upgradeToCollaboration()`, the visible runtime is torn down and - * rebuilt. These helpers create a frozen snapshot overlay so the user sees - * no visual discontinuity while the new runtime initializes. - */ - -/** - * Capture the visible `.superdoc` DOM into an inert overlay before teardown. - * - * Returns a snapshot descriptor (overlay element + restore callback), or - * `null` if the DOM state doesn't allow snapshotting (graceful degradation). - * - * @param {HTMLElement | null} container The SuperDoc host element - * @param {HTMLElement | null} mountWrapper The internal Vue mount wrapper - * @returns {{ overlay: HTMLDivElement, restore: () => void } | null} - */ -export function createUpgradeSnapshot(container, mountWrapper) { - const superdocEl = mountWrapper?.querySelector('.superdoc'); - if (!container || !superdocEl) return null; - - // Pin container geometry so the page doesn't collapse during unmount - const rect = container.getBoundingClientRect(); - const prevMinHeight = container.style.minHeight; - const prevPosition = container.style.position; - container.style.minHeight = `${rect.height}px`; - if (window.getComputedStyle(container).position === 'static') { - container.style.position = 'relative'; - } - - const scrollTop = container.scrollTop; - const scrollLeft = container.scrollLeft; - - // Clone the visible tree - const clone = superdocEl.cloneNode(true); - - // Copy canvas pixels — cloneNode doesn't preserve them - const srcCanvases = superdocEl.querySelectorAll('canvas'); - const dstCanvases = clone.querySelectorAll('canvas'); - for (let i = 0; i < srcCanvases.length; i++) { - try { - const ctx = dstCanvases[i]?.getContext('2d'); - if (ctx) ctx.drawImage(srcCanvases[i], 0, 0); - } catch { - /* tainted canvas — skip */ - } - } - - // Build overlay - const overlay = document.createElement('div'); - overlay.className = 'sd-upgrade-overlay'; - overlay.setAttribute('aria-hidden', 'true'); - overlay.appendChild(clone); - container.appendChild(overlay); - - return { - overlay, - restore() { - container.style.minHeight = prevMinHeight; - container.style.position = prevPosition; - container.scrollTop = scrollTop; - container.scrollLeft = scrollLeft; - }, - }; -} - -/** - * Reveal the new runtime and clean up the snapshot overlay. - * - * @param {HTMLElement | null} mountWrapper - * @param {HTMLDivElement | null} overlay - * @param {{ restore: () => void } | null} snapshot - */ -export function revealNewRuntime(mountWrapper, overlay, snapshot) { - const newEl = mountWrapper?.querySelector('.superdoc'); - if (newEl) newEl.classList.remove('sd-upgrade-hidden'); - snapshot?.restore(); - if (overlay) overlay.remove(); -} - -/** - * Emergency cleanup if an upgrade transition is aborted (e.g. by `destroy()`). - * - * @param {HTMLElement | null} mountWrapper - * @param {HTMLDivElement | null} overlay - * @param {{ restore?: () => void } | null} snapshot - */ -export function teardownUpgradeTransition(mountWrapper, overlay, snapshot) { - if (overlay) overlay.remove(); - snapshot?.restore(); - const el = mountWrapper?.querySelector('.superdoc'); - if (el) el.classList.remove('sd-upgrade-hidden'); -} diff --git a/packages/superdoc/src/core/upgrade-collaboration.test.js b/packages/superdoc/src/core/upgrade-collaboration.test.js index 6884debb0d..8942b426cc 100644 --- a/packages/superdoc/src/core/upgrade-collaboration.test.js +++ b/packages/superdoc/src/core/upgrade-collaboration.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { shallowRef, reactive } from 'vue'; import { DOCX, PDF } from '@superdoc/common'; // --------------------------------------------------------------------------- @@ -11,11 +12,8 @@ vi.mock('@superdoc/common/collaboration/awareness', () => ({ vi.mock('uuid', () => ({ v4: vi.fn(() => 'uuid-test') })); -// --- super-editor --- - const seedEditorStateToYDocMock = vi.fn(); -const onCollaborationProviderSyncedMock = vi.fn((provider, cb) => { - // Immediately report synced by default (tests can override) +const onCollaborationProviderSyncedMock = vi.fn((_, cb) => { cb(); return () => {}; }); @@ -40,8 +38,6 @@ vi.mock('@superdoc/super-editor', () => ({ onCollaborationProviderSynced: onCollaborationProviderSyncedMock, })); -// --- collaboration helpers --- - const initCollaborationCommentsMock = vi.fn(); vi.mock('./collaboration/helpers.js', () => ({ @@ -59,8 +55,6 @@ vi.mock('./collaboration/collaboration.js', () => ({ setupAwarenessHandler: setupAwarenessHandlerMock, })); -// --- room overwrite --- - const overwriteRoomCommentsMock = vi.fn(); const overwriteRoomLockStateMock = vi.fn(); @@ -69,8 +63,6 @@ vi.mock('./collaboration/room-overwrite.js', () => ({ overwriteRoomLockState: overwriteRoomLockStateMock, })); -// --- other mocks --- - vi.mock('../components/CommentsLayer/commentsList/super-comments-list.js', () => ({ SuperComments: vi.fn(), })); @@ -99,59 +91,9 @@ vi.mock('@hocuspocus/provider', () => ({ HocuspocusProviderWebsocket: vi.fn(), })); -// --- Vue app harness --- - const createVueAppMock = vi.fn(); vi.mock('./create-app.js', () => ({ createSuperdocVueApp: createVueAppMock })); -function createAppHarness({ commentsList = [] } = {}) { - const mockEditor = createMockEditor(); - - const superdocStore = { - documents: [ - { - id: 'doc-1', - type: DOCX, - getEditor: () => mockEditor, - setEditor: vi.fn(), - }, - ], - init: vi.fn(), - reset: vi.fn(), - setExceptionHandler: vi.fn(), - activeZoom: 100, - }; - - const commentsStore = { - init: vi.fn(), - commentsList, - translateCommentsForExport: vi.fn(() => []), - handleEditorLocationsUpdate: vi.fn(), - hasSyncedCollaborationComments: false, - commentsParentElement: null, - editorCommentIds: [], - removePendingComment: vi.fn(), - setActiveComment: vi.fn(), - }; - - const app = { - mount: vi.fn(), - unmount: vi.fn(), - provide: vi.fn(), - config: { globalProperties: {} }, - }; - - createVueAppMock.mockReturnValue({ - app, - pinia: {}, - superdocStore, - commentsStore, - highContrastModeStore: {}, - }); - - return { app, superdocStore, commentsStore, mockEditor }; -} - function createMockEditor() { const docJson = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'user edits' }] }] }; return { @@ -206,52 +148,108 @@ function createMockYDoc() { }; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - const flushMicrotasks = async () => { await Promise.resolve(); await Promise.resolve(); }; -/** - * Mount mock that creates a `.superdoc` element (simulating Vue render) - * and fires the upgrade visual-ready callback (or falls back to the `ready` - * event for non-upgrade mounts like initial construction). - */ -function makeUpgradeAwareMountMock(instance) { - return (wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } else { - instance.emit('ready', { superdoc: instance }); - } - }, 0); +function baseConfig(overrides = {}) { + return { + modules: { comments: {} }, + colors: [], + onException: vi.fn(), + ...overrides, }; } -/** - * Ensure the initial mount creates a `.superdoc` element so the snapshot - * code has something to clone during upgrade-transition tests. - */ -function makeInitialMountMock() { - return (wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - el.innerHTML = '

Initial content

'; - wrapper.appendChild(el); +function createUpgradeHarness({ commentsList = [], attachImpl } = {}) { + const mockEditor = createMockEditor(); + const innerEditor = { + ...mockEditor, + options: { ...mockEditor.options, collaborationIsReady: false }, + on: vi.fn(), + off: vi.fn(), }; -} -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- + const attachCollaborationMock = vi.fn(() => { + if (attachImpl) { + return attachImpl(innerEditor); + } + innerEditor.options.collaborationIsReady = true; + }); + + const editorInstance = { + ...mockEditor, + editor: innerEditor, + attachCollaboration: attachCollaborationMock, + }; + + const storeDoc = { + id: 'doc-1', + type: DOCX, + getEditor: () => editorInstance, + getPresentationEditor: () => editorInstance, + setEditor: vi.fn(), + // Use real Vue shallowRefs to match use-document.js composable behavior. + // Wrapping documents in reactive() below simulates Pinia's reactive store, + // which auto-unwraps shallowRefs on property access through the proxy. + ydoc: shallowRef(null), + provider: shallowRef(null), + }; + + const superdocStore = { + // reactive() simulates Pinia's ref([]) store behavior: items accessed + // through the reactive array become reactive proxies that auto-unwrap + // shallowRef properties — the code must use toRaw() to reach .value. + documents: reactive([storeDoc]), + init: vi.fn(), + reset: vi.fn(), + setExceptionHandler: vi.fn(), + activeZoom: 100, + }; + + const commentsStore = { + init: vi.fn(), + commentsList, + translateCommentsForExport: vi.fn(() => []), + handleEditorLocationsUpdate: vi.fn(), + hasSyncedCollaborationComments: false, + commentsParentElement: null, + editorCommentIds: [], + removePendingComment: vi.fn(), + setActiveComment: vi.fn(), + }; + + const app = { + mount: vi.fn((wrapper) => { + const el = document.createElement('div'); + el.className = 'superdoc'; + wrapper.appendChild(el); + }), + unmount: vi.fn(), + provide: vi.fn(), + config: { globalProperties: {} }, + }; + + createVueAppMock.mockReturnValue({ + app, + pinia: {}, + superdocStore, + commentsStore, + highContrastModeStore: {}, + }); + + return { + app, + superdocStore, + commentsStore, + mockEditor, + innerEditor, + editorInstance, + attachCollaborationMock, + storeDoc, + }; +} let consoleDebugSpy; let consoleLogSpy; @@ -288,18 +286,12 @@ describe('upgradeToCollaboration', () => { vi.clearAllMocks(); }); - // ----------------------------------------------------------------------- - // Happy path - // ----------------------------------------------------------------------- - - it('upgrades a local instance into collaboration mode', async () => { - const { app, superdocStore, mockEditor } = createAppHarness(); + it('upgrades a local instance into collaboration mode without unmounting the app', async () => { + const { app, editorInstance, attachCollaborationMock } = createUpgradeHarness(); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; @@ -307,236 +299,231 @@ describe('upgradeToCollaboration', () => { const ydoc = createMockYDoc(); const provider = createMockProvider(); - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - await instance.upgradeToCollaboration({ ydoc, provider }); expect(instance.isCollaborative).toBe(true); - expect(seedEditorStateToYDocMock).toHaveBeenCalledWith(mockEditor, ydoc); - expect(overwriteRoomCommentsMock).toHaveBeenCalledWith(ydoc, expect.anything()); - expect(overwriteRoomLockStateMock).toHaveBeenCalledWith(ydoc, { - isLocked: false, - lockedBy: null, - }); + expect(app.unmount).not.toHaveBeenCalled(); + expect(seedEditorStateToYDocMock).toHaveBeenCalledWith(editorInstance, ydoc); + expect(attachCollaborationMock).toHaveBeenCalledWith({ ydoc, collaborationProvider: provider }); }); it('waits for provider sync before seeding', async () => { - const { app } = createAppHarness(); + createUpgradeHarness(); + let syncCallback; + onCollaborationProviderSyncedMock.mockImplementation((_, cb) => { + syncCallback = cb; + return () => {}; + }); + const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; - let syncCallback; - onCollaborationProviderSyncedMock.mockImplementation((_, cb) => { - syncCallback = cb; - return () => {}; + const upgradePromise = instance.upgradeToCollaboration({ + ydoc: createMockYDoc(), + provider: createMockProvider({ synced: false }), }); - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - const ydoc = createMockYDoc(); - const provider = createMockProvider({ synced: false }); - - const upgradePromise = instance.upgradeToCollaboration({ ydoc, provider }); - - // Seed should NOT have been called yet (provider not synced) expect(seedEditorStateToYDocMock).not.toHaveBeenCalled(); - // Now report synced syncCallback(); await upgradePromise; - expect(seedEditorStateToYDocMock).toHaveBeenCalled(); + expect(seedEditorStateToYDocMock).toHaveBeenCalledTimes(1); }); - it('preserves document ids across upgrade', async () => { - const { app, superdocStore } = createAppHarness(); - superdocStore.documents[0].id = 'my-doc-id'; + it('preserves document ids and transfers lock state during upgrade', async () => { + const { storeDoc } = createUpgradeHarness(); + storeDoc.id = 'my-doc-id'; const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'my-doc-id', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig({ isLocked: true, lockedBy: { name: 'Alice' } }), }); await flushMicrotasks(); instance.readyEditors = 1; - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); + const ydoc = createMockYDoc(); + await instance.upgradeToCollaboration({ ydoc, provider: createMockProvider() }); expect(instance.config.documents[0].id).toBe('my-doc-id'); + expect(overwriteRoomLockStateMock).toHaveBeenCalledWith(ydoc, { + isLocked: true, + lockedBy: { name: 'Alice' }, + }); }); - it('transfers lock state during upgrade', async () => { - const { app } = createAppHarness(); + it('updates store documents with ydoc/provider and wires collaboration comments', async () => { + const { storeDoc } = createUpgradeHarness(); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - isLocked: true, - lockedBy: { name: 'Alice' }, - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - const ydoc = createMockYDoc(); - await instance.upgradeToCollaboration({ ydoc, provider: createMockProvider() }); + const provider = createMockProvider(); - expect(overwriteRoomLockStateMock).toHaveBeenCalledWith(ydoc, { - isLocked: true, - lockedBy: { name: 'Alice' }, - }); + await instance.upgradeToCollaboration({ ydoc, provider }); + + expect(storeDoc.ydoc.value).toBe(ydoc); + expect(storeDoc.provider.value).toBe(provider); + expect(initCollaborationCommentsMock).toHaveBeenCalledWith(instance); }); - // ----------------------------------------------------------------------- - // Validation - // ----------------------------------------------------------------------- + it('rolls back if attachCollaboration throws', async () => { + const { attachCollaborationMock, storeDoc } = createUpgradeHarness(); + attachCollaborationMock.mockImplementation(() => { + throw new Error('Attach failed'); + }); - it('throws when instance is already collaborative', async () => { - const { app } = createAppHarness(); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { - comments: {}, - collaboration: { ydoc: createMockYDoc(), provider: createMockProvider() }, - }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); + instance.readyEditors = 1; await expect( - instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), - ).rejects.toThrow('already collaborative'); + instance.upgradeToCollaboration({ + ydoc: createMockYDoc(), + provider: createMockProvider(), + }), + ).rejects.toThrow('Attach failed'); + + expect(instance.isCollaborative).toBe(false); + expect(awarenessCleanupSpy).toHaveBeenCalled(); + expect(storeDoc.ydoc.value).toBeNull(); + expect(storeDoc.provider.value).toBeNull(); }); - it('throws when ydoc is missing', async () => { - createAppHarness(); + it('does not create a DOM snapshot overlay', async () => { + createUpgradeHarness(); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); + instance.readyEditors = 1; - await expect(instance.upgradeToCollaboration({ ydoc: null, provider: createMockProvider() })).rejects.toThrow( - 'requires both ydoc and provider', - ); + await instance.upgradeToCollaboration({ + ydoc: createMockYDoc(), + provider: createMockProvider(), + }); + + expect(document.getElementById('host').querySelector('.sd-upgrade-overlay')).toBeNull(); }); - it('throws when provider is missing', async () => { - createAppHarness(); + it('throws when instance is already collaborative', async () => { + createUpgradeHarness(); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig({ + modules: { + comments: {}, + collaboration: { ydoc: createMockYDoc(), provider: createMockProvider() }, + }, + }), }); await flushMicrotasks(); + await expect( + instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), + ).rejects.toThrow('already collaborative'); + }); + + it('throws when ydoc or provider is missing', async () => { + createUpgradeHarness(); + const instance = new SuperDoc({ + selector: '#host', + documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], + ...baseConfig(), + }); + await flushMicrotasks(); + + await expect(instance.upgradeToCollaboration({ ydoc: null, provider: createMockProvider() })).rejects.toThrow( + 'requires both ydoc and provider', + ); await expect(instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: null })).rejects.toThrow( 'requires both ydoc and provider', ); }); - it('throws for multi-DOCX instances', async () => { - createAppHarness(); - const instance = new SuperDoc({ + it('throws for unsupported document sets', async () => { + createUpgradeHarness(); + + const multiDocx = new SuperDoc({ selector: '#host', documents: [ { id: 'doc-1', type: DOCX, data: new Blob() }, { id: 'doc-2', type: DOCX, data: new Blob() }, ], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); await expect( - instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), + multiDocx.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), ).rejects.toThrow('single DOCX'); - }); - it('throws for instances with non-DOCX documents', async () => { - createAppHarness(); - const instance = new SuperDoc({ + createUpgradeHarness(); + const mixedDocs = new SuperDoc({ selector: '#host', documents: [ { id: 'doc-1', type: DOCX, data: new Blob() }, { id: 'pdf-1', type: PDF, data: new Blob() }, ], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); await expect( - instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), + mixedDocs.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), ).rejects.toThrow('single-DOCX'); }); - it('throws when editor is not ready', async () => { - const harness = createAppHarness(); - // Override getEditor to return null (editor not created yet) - harness.superdocStore.documents[0].getEditor = () => null; + it('throws when the source editor is not ready or the instance is destroyed', async () => { + const harness = createUpgradeHarness(); + harness.storeDoc.getPresentationEditor = () => null; + harness.storeDoc.getEditor = () => null; const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); await expect( instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), ).rejects.toThrow('source editor not yet created'); - }); - it('throws when instance is destroyed', async () => { - createAppHarness(); - const instance = new SuperDoc({ + createUpgradeHarness(); + const destroyedInstance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); - - instance.destroy(); + destroyedInstance.destroy(); await expect( - instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), + destroyedInstance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider() }), ).rejects.toThrow('destroyed'); }); it('prevents concurrent upgrades', async () => { - const { app } = createAppHarness(); - + createUpgradeHarness(); let syncResolve; onCollaborationProviderSyncedMock.mockImplementation((_, cb) => { syncResolve = cb; @@ -546,15 +533,11 @@ describe('upgradeToCollaboration', () => { const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - const first = instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider({ synced: false }), @@ -571,52 +554,15 @@ describe('upgradeToCollaboration', () => { await first; }); - it('rejects immediately if destroyed during provider sync wait', async () => { - createAppHarness(); - - // Hold the sync callback — never call it; destroy should abort the wait - onCollaborationProviderSyncedMock.mockImplementation(() => { - return () => {}; // cleanup - }); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const upgradePromise = instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider({ synced: false }), - }); - - // Destroy while waiting for sync — should abort the wait immediately - instance.destroy(); - - await expect(upgradePromise).rejects.toThrow('destroyed during upgrade'); - - // Seeding should NOT have happened - expect(seedEditorStateToYDocMock).not.toHaveBeenCalled(); - }); - - it('cleans up sync listener when destroyed during sync wait', async () => { - createAppHarness(); - + it('rejects immediately and cleans up the sync listener when destroyed during provider sync wait', async () => { + createUpgradeHarness(); const syncCleanupSpy = vi.fn(); - onCollaborationProviderSyncedMock.mockImplementation(() => { - return syncCleanupSpy; - }); + onCollaborationProviderSyncedMock.mockImplementation(() => syncCleanupSpy); const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; @@ -626,659 +572,63 @@ describe('upgradeToCollaboration', () => { provider: createMockProvider({ synced: false }), }); - // Destroy aborts immediately and cleans up the sync listener instance.destroy(); await expect(upgradePromise).rejects.toThrow('destroyed during upgrade'); expect(syncCleanupSpy).toHaveBeenCalled(); + expect(seedEditorStateToYDocMock).not.toHaveBeenCalled(); }); - it('rejects if destroyed during collaborative remount wait', async () => { - const { app } = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - // Do NOT call visual-ready callback — simulate a runtime that hasn't - // finished initializing when destroy() is called. - app.mount.mockImplementation((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - }); - - const upgradePromise = instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - await flushMicrotasks(); - instance.destroy(); - - await expect(upgradePromise).rejects.toThrow('destroyed during upgrade'); - expect(instance.isCollaborative).toBe(true); // detach was skipped - }); - - // ----------------------------------------------------------------------- - // Runtime teardown / remount - // ----------------------------------------------------------------------- - - it('does not call removeAllListeners during upgrade', async () => { - const { app } = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const removeAllSpy = vi.spyOn(instance, 'removeAllListeners'); - - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - expect(removeAllSpy).not.toHaveBeenCalled(); - }); - - it('resets readyEditors to 0 during remount', async () => { - const { app } = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - let readyEditorsAtMount; - app.mount.mockImplementation((wrapper) => { - readyEditorsAtMount = instance.readyEditors; - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - expect(readyEditorsAtMount).toBe(0); - }); - - it('unmounts and remounts the Vue app', async () => { - const { app } = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - expect(app.unmount).toHaveBeenCalled(); - expect(createVueAppMock.mock.calls.length).toBeGreaterThanOrEqual(2); - }); - - it('triggers rollback preserving local state, awareness cleanup, and comments list when remount fails', async () => { - const harness = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - instance.commentsList = { close: vi.fn() }; - const addCommentsListSpy = vi.spyOn(instance, 'addCommentsList').mockImplementation(() => {}); - - let jsonOverrideDuringRollback; - let callCount = 0; - const successResult = () => ({ - app: { - mount: vi.fn((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }), - unmount: vi.fn(), - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: harness.superdocStore, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }); - createVueAppMock.mockImplementation(() => { - callCount++; - if (callCount === 1) { - throw new Error('Simulated remount failure'); - } - jsonOverrideDuringRollback = instance.config.jsonOverride; - return successResult(); - }); - - let caughtError; - try { - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - } catch (err) { - caughtError = err; - } - - expect(caughtError?.message).toBe('Simulated remount failure'); - expect(awarenessCleanupSpy).toHaveBeenCalled(); - expect(instance.isCollaborative).toBe(false); - expect(addCommentsListSpy).toHaveBeenCalled(); - expect(jsonOverrideDuringRollback).toEqual({ - type: 'doc', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'user edits' }] }], - }); - expect(instance.config.jsonOverride).toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Upgrade transition (snapshot overlay) - // ----------------------------------------------------------------------- - - it('creates a snapshot overlay before teardown and removes it after visual-ready', async () => { - const { app } = createAppHarness(); - app.mount.mockImplementation(makeInitialMountMock()); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const host = document.getElementById('host'); - - // Override mount for the upgrade remount - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - // Overlay should have been removed after success - expect(host.querySelector('.sd-upgrade-overlay')).toBeNull(); - // New .superdoc should not have the hidden class - const newSuperdoc = host.querySelector('.superdoc'); - expect(newSuperdoc?.classList.contains('sd-upgrade-hidden')).toBe(false); - }); - - it('pins container geometry during transition and restores it after', async () => { - const { app } = createAppHarness(); - app.mount.mockImplementation(makeInitialMountMock()); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const host = document.getElementById('host'); - const originalMinHeight = host.style.minHeight; - - let minHeightDuringMount; - app.mount.mockImplementation((wrapper) => { - minHeightDuringMount = host.style.minHeight; - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - // Min-height should have been set during transition (pinned) - expect(minHeightDuringMount).toBeTruthy(); - // Min-height should be restored after reveal - expect(host.style.minHeight).toBe(originalMinHeight); - }); - - it('cleans up overlay when destroy is called during the upgrade transition', async () => { - const { app } = createAppHarness(); - app.mount.mockImplementation(makeInitialMountMock()); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const host = document.getElementById('host'); - - // Don't call visual-ready callback — leave the upgrade transition in progress - app.mount.mockImplementation((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - }); - - const upgradePromise = instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - await flushMicrotasks(); - - // Overlay should exist while the upgrade transition is in progress - expect(host.querySelector('.sd-upgrade-overlay')).not.toBeNull(); - - instance.destroy(); - await expect(upgradePromise).rejects.toThrow('destroyed'); - - // Overlay should be cleaned up - expect(host.querySelector('.sd-upgrade-overlay')).toBeNull(); - expect(instance._upgradeVisualReadyCallback).toBeNull(); - }); - - it('degrades gracefully when no .superdoc element exists for snapshot', async () => { - const { app } = createAppHarness(); - // Initial mount does NOT create a .superdoc element - app.mount.mockImplementation(() => {}); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - // Upgrade mount: create .superdoc and fire callback - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - // Should complete without error even though snapshot was null - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - expect(instance.isCollaborative).toBe(true); - }); - - it('waits for visual-ready callback, not the ready event', async () => { - const { app } = createAppHarness(); - app.mount.mockImplementation(makeInitialMountMock()); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - let visualReadyCallbackCalled = false; - - // Mount creates a .superdoc but does NOT call the visual-ready callback. - // Instead, it emits the 'ready' event (old path). The upgrade should - // NOT resolve from 'ready' alone. - app.mount.mockImplementation((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - instance.emit('ready', { superdoc: instance }); - // Then after a further tick, call the visual-ready callback - setTimeout(() => { - visualReadyCallbackCalled = true; - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }, 0); - }); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - // The upgrade should have waited for the visual-ready callback - expect(visualReadyCallbackCalled).toBe(true); - }); - - it('sets _upgradeVisualReadyCallback only during upgrade', async () => { - const { app } = createAppHarness(); - app.mount.mockImplementation(makeInitialMountMock()); - - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - // Before upgrade: no callback - expect(instance._upgradeVisualReadyCallback).toBeFalsy(); - - app.mount.mockImplementation(makeUpgradeAwareMountMock(instance)); - - await instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }); - - // After upgrade: callback cleared - expect(instance._upgradeVisualReadyCallback).toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Rollback regression: leaked runtime (Fix 1) - // ----------------------------------------------------------------------- - - it('stops the failed collaborative runtime before starting rollback (mount throws)', async () => { - const harness = createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), - }); - await flushMicrotasks(); - instance.readyEditors = 1; - - const collabUnmount = vi.fn(); - let callCount = 0; - - createVueAppMock.mockImplementation(() => { - callCount++; - if (callCount === 1) { - // Collaborative runtime: app created but mount throws - return { - app: { - mount: vi.fn(() => { - throw new Error('Collaborative mount failed'); - }), - unmount: collabUnmount, - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: harness.superdocStore, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }; - } - // Rollback runtime: succeeds - return { - app: { - mount: vi.fn((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }), - unmount: vi.fn(), - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: harness.superdocStore, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }; - }); - - await expect( - instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }), - ).rejects.toThrow('Collaborative mount failed'); - - // The collaborative app must be unmounted via #stopRuntime() before rollback - expect(collabUnmount).toHaveBeenCalled(); - }); - - it('stops a timed-out collaborative runtime before starting rollback', async () => { + it('resolves successfully even when collaborationReady times out', async () => { vi.useFakeTimers(); try { - const harness = createAppHarness(); + const harness = createUpgradeHarness({ attachImpl: () => {} }); + harness.innerEditor.options.collaborationIsReady = false; + const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await vi.advanceTimersByTimeAsync(0); instance.readyEditors = 1; - const collabUnmount = vi.fn(); - let callCount = 0; - - createVueAppMock.mockImplementation(() => { - callCount++; - if (callCount === 1) { - // Collaborative runtime: mounts but never fires visual-ready - return { - app: { - mount: vi.fn((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - // visual-ready callback deliberately NOT called - }), - unmount: collabUnmount, - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: harness.superdocStore, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }; - } - // Rollback runtime: fires visual-ready immediately - return { - app: { - mount: vi.fn((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - // Fire visual-ready synchronously to avoid timer complications - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }), - unmount: vi.fn(), - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: harness.superdocStore, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }; - }); - const upgradePromise = instance.upgradeToCollaboration({ ydoc: createMockYDoc(), provider: createMockProvider(), }); - // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection - const resultPromise = expect(upgradePromise).rejects.toThrow('visually ready within 30 s'); - - // Advance past the 30s collaborative timeout - await vi.advanceTimersByTimeAsync(30_001); - - await resultPromise; + await vi.advanceTimersByTimeAsync(10_001); + await upgradePromise; - // The timed-out collaborative app must be unmounted before rollback - expect(collabUnmount).toHaveBeenCalled(); + expect(instance.isCollaborative).toBe(true); + expect(initCollaborationCommentsMock).toHaveBeenCalledWith(instance); } finally { vi.useRealTimers(); } }); - // ----------------------------------------------------------------------- - // Rollback regression: incomplete state restoration (Fix 2) - // ----------------------------------------------------------------------- - - it('restores convertedXml and mediaFiles on the rollback editor', async () => { - const harness = createAppHarness(); - - // Simulate pre-upgrade edits that modified parts and media - harness.mockEditor.converter.convertedXml = { - 'word/styles.xml': { tag: 'w:styles', children: [{ tag: 'w:style', attrs: { id: 'custom' } }] }, - 'word/numbering.xml': { tag: 'w:numbering', children: [{ tag: 'w:abstractNum' }] }, - }; - harness.mockEditor.options.mediaFiles = { - 'word/media/image1.png': 'base64-data-here', - }; + it('resolves immediately when destroy() is called during readiness wait without reinitializing comments', async () => { + const harness = createUpgradeHarness({ attachImpl: () => {} }); + harness.innerEditor.options.collaborationIsReady = false; const instance = new SuperDoc({ selector: '#host', documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }], - modules: { comments: {} }, - colors: [], - onException: vi.fn(), + ...baseConfig(), }); await flushMicrotasks(); instance.readyEditors = 1; - // The rollback editor starts with empty/reimported state - const rollbackEditor = { - converter: { convertedXml: {} }, - options: { mediaFiles: {}, fonts: {} }, - state: harness.mockEditor.state, - getJSON: harness.mockEditor.getJSON, - }; - - let callCount = 0; - createVueAppMock.mockImplementation(() => { - callCount++; - if (callCount === 1) { - // Collaborative: fails during createSuperdocVueApp - throw new Error('Simulated collab failure'); - } - // Rollback: succeeds, returns a store with the rollback editor - return { - app: { - mount: vi.fn((wrapper) => { - const el = document.createElement('div'); - el.className = 'superdoc'; - wrapper.appendChild(el); - setTimeout(() => { - if (instance._upgradeVisualReadyCallback) { - instance._upgradeVisualReadyCallback(); - } - }, 0); - }), - unmount: vi.fn(), - provide: vi.fn(), - config: { globalProperties: {} }, - }, - pinia: {}, - superdocStore: { - documents: [ - { - id: 'doc-1', - type: DOCX, - getEditor: () => rollbackEditor, - setEditor: vi.fn(), - }, - ], - init: vi.fn(), - reset: vi.fn(), - setExceptionHandler: vi.fn(), - activeZoom: 100, - }, - commentsStore: harness.commentsStore, - highContrastModeStore: {}, - }; + const upgradePromise = instance.upgradeToCollaboration({ + ydoc: createMockYDoc(), + provider: createMockProvider(), }); - await expect( - instance.upgradeToCollaboration({ - ydoc: createMockYDoc(), - provider: createMockProvider(), - }), - ).rejects.toThrow('Simulated collab failure'); + await flushMicrotasks(); + instance.destroy(); + await upgradePromise; - // The rollback editor should have the pre-upgrade parts and media restored - expect(rollbackEditor.converter.convertedXml).toEqual({ - 'word/styles.xml': { tag: 'w:styles', children: [{ tag: 'w:style', attrs: { id: 'custom' } }] }, - 'word/numbering.xml': { tag: 'w:numbering', children: [{ tag: 'w:abstractNum' }] }, - }); - expect(rollbackEditor.options.mediaFiles).toEqual({ - 'word/media/image1.png': 'base64-data-here', - }); + expect(initCollaborationCommentsMock).not.toHaveBeenCalled(); }); });