From 0c45bcbdacb2e4b37e91cc117fb5a7f91e3d81f9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 15 Feb 2026 19:48:16 -0300 Subject: [PATCH 1/2] fix(collaboration): memory leaks, Vue stack overflow, and Liveblocks stability - Fix Y.js observer leaks in collaboration extension by adding onDestroy lifecycle hook with proper cleanup for media map, header/footer map, and afterTransaction listeners via module-level WeakMap - Fix yUndoPlugin observer leak in #prepareDocumentForExport by using Transform directly instead of creating a throwaway EditorState - Fix Vue traverse stack overflow by wrapping Y.js objects (ydoc, provider) with markRaw() before storing on the SuperDoc instance - Fix user color blinking by assigning a stable color on the external provider path before awareness broadcast - Fix Liveblocks room corruption by guarding against duplicate SuperDoc creation on provider reconnect (sync event fires on every reconnect) - Debounce local cursor awareness updates (100ms) to avoid ~190ms Liveblocks overhead per keystroke - Defer Vue selection state updates to RAF to prevent ~300ms flushJobs blocking per keystroke - Fix block-node hasInitialized flag to prevent repeated full-document traversals on every transaction - Fix debounce utility: use fn(...args) instead of fn.apply(this, args) and add .cancel() support for proper cleanup - Refactor Liveblocks example: extract useSuperdocCollaboration hook, hoist static styles, fix Strict Mode cleanup, correct awareness state property access --- examples/collaboration/liveblocks/src/App.tsx | 70 +++++++++++++++---- .../collaboration/liveblocks/vite.config.js | 12 ++++ packages/super-editor/src/core/Editor.ts | 20 +++--- .../presentation-editor/PresentationEditor.ts | 42 +++++++---- .../src/extensions/block-node/block-node.js | 6 +- .../extensions/collaboration/collaboration.js | 59 +++++++++++++--- packages/superdoc/src/SuperDoc.test.js | 13 +++- packages/superdoc/src/SuperDoc.vue | 20 ++++++ packages/superdoc/src/core/SuperDoc.js | 24 +++++-- 9 files changed, 210 insertions(+), 56 deletions(-) diff --git a/examples/collaboration/liveblocks/src/App.tsx b/examples/collaboration/liveblocks/src/App.tsx index 9bf97689ec..9bed817ad1 100644 --- a/examples/collaboration/liveblocks/src/App.tsx +++ b/examples/collaboration/liveblocks/src/App.tsx @@ -1,16 +1,26 @@ -import { useEffect, useRef, useState } from 'react'; import { createClient } from '@liveblocks/client'; import { LiveblocksYjsProvider } from '@liveblocks/yjs'; -import * as Y from 'yjs'; -import 'superdoc/style.css'; +import { CSSProperties, useEffect, useRef, useState } from 'react'; import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; +import * as Y from 'yjs'; const PUBLIC_KEY = import.meta.env.VITE_LIVEBLOCKS_PUBLIC_KEY as string; -const ROOM_ID = (import.meta.env.VITE_ROOM_ID as string) || 'superdoc-room'; +const ROOM_ID = (import.meta.env.VITE_ROOM_ID as string) || 'superdoc-markraw-v7'; -export default function App() { +// --------------------------------------------------------------------------- +// Hook: useSuperdocCollaboration +// --------------------------------------------------------------------------- + +interface CollaborationState { + users: any[]; + synced: boolean; +} + +function useSuperdocCollaboration(userName: string): CollaborationState { const superdocRef = useRef(null); const [users, setUsers] = useState([]); + const [synced, setSynced] = useState(false); useEffect(() => { if (!PUBLIC_KEY) return; @@ -20,29 +30,60 @@ export default function App() { const ydoc = new Y.Doc(); const provider = new LiveblocksYjsProvider(room, ydoc); - provider.on('sync', (synced: boolean) => { - if (!synced) return; + provider.on('sync', (isSynced: boolean) => { + if (!isSynced) return; + // Guard: only create SuperDoc once. Liveblocks fires 'sync' again on + // reconnect, which would create duplicate editors writing to the same + // Y.js doc — corrupting the room state (code 1011). + if (superdocRef.current) return; + setSynced(true); superdocRef.current = new SuperDoc({ selector: '#superdoc', documentMode: 'editing', - user: { name: `User ${Math.floor(Math.random() * 1000)}`, email: 'user@example.com' }, + user: { name: userName, email: `${userName.toLowerCase().replace(' ', '-')}@example.com` }, modules: { collaboration: { ydoc, provider }, }, - onAwarenessUpdate: ({ states }: any) => setUsers(states.filter((s: any) => s.user)), + onAwarenessUpdate: ({ states }: any) => setUsers(states), + onEditorCreate: ({ editor }: any) => { + (window as any).editor = editor; + }, }); }); return () => { superdocRef.current?.destroy(); + superdocRef.current = null; + setSynced(false); provider.destroy(); leave(); }; - }, []); + }, [userName]); + + return { users, synced }; +} + +// --------------------------------------------------------------------------- +// Component: App +// --------------------------------------------------------------------------- + +const connectingStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 200, + color: '#888', +}; + +const missingKeyStyle: CSSProperties = { padding: '2rem' }; + +export default function App() { + const [userName] = useState(() => `User ${Math.floor(Math.random() * 1000)}`); + const { users, synced } = useSuperdocCollaboration(userName); if (!PUBLIC_KEY) { - return
Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env
; + return
Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env
; } return ( @@ -50,14 +91,15 @@ export default function App() {

SuperDoc + Liveblocks

- {users.map((u, i) => ( - - {u.user?.name} + {users.map((u) => ( + + {u.name || u.email} ))}
+ {!synced &&
Connecting…
}
diff --git a/examples/collaboration/liveblocks/vite.config.js b/examples/collaboration/liveblocks/vite.config.js index 556f5198df..563e7ae2b9 100644 --- a/examples/collaboration/liveblocks/vite.config.js +++ b/examples/collaboration/liveblocks/vite.config.js @@ -1,9 +1,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import path from 'path'; + +const superdocPkg = path.resolve(__dirname, '../../../packages/superdoc'); export default defineConfig({ plugins: [react()], + resolve: { + alias: { + 'superdoc/style.css': path.join(superdocPkg, 'dist/style.css'), + superdoc: path.join(superdocPkg, 'dist/superdoc.es.js'), + }, + }, server: { port: 3000, + fs: { + allow: [superdocPkg, '.'], + }, }, }); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 4e18b8770c..7e2f65ae5a 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -1,4 +1,5 @@ import type { EditorState, Transaction, Plugin } from 'prosemirror-state'; +import { Transform } from 'prosemirror-transform'; import type { EditorView as PmEditorView } from 'prosemirror-view'; import type { Node as PmNode, Schema } from 'prosemirror-model'; import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js'; @@ -2123,6 +2124,7 @@ export class Editor extends EventEmitter { } const end = perfNow(); + this.emit('transaction', { editor: this, transaction: transactionToApply, @@ -2498,17 +2500,15 @@ export class Editor extends EventEmitter { * @returns The updated document in JSON */ #prepareDocumentForExport(comments: Comment[] = []): ProseMirrorJSON { - const newState = PmEditorState.create({ - schema: this.schema, - doc: this.state.doc, - plugins: this.state.plugins, - }); - - const { tr, doc } = newState; - + // Use Transform directly instead of creating a throwaway EditorState. + // EditorState.create() calls Plugin.init() for every plugin, and + // yUndoPlugin.init() registers persistent observers on the shared ydoc + // that are never cleaned up — causing an observer leak that degrades + // collaboration performance over time. + const doc = this.state.doc; + const tr = new Transform(doc); prepareCommentsForExport(doc, tr, this.schema, comments); - const updatedState = newState.apply(tr); - return updatedState.doc.toJSON(); + return tr.doc.toJSON(); } getUpdatedJson(): ProseMirrorJSON { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 015ef89d25..01042195a6 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -193,7 +193,7 @@ const layoutDebugEnabled = /** Log performance metrics when debug is enabled */ const perfLog = (...args: unknown[]): void => { if (!layoutDebugEnabled) return; - console.log(...args); + console.warn(...args); }; /** Budget for header/footer initialization before warning (milliseconds) */ const HEADER_FOOTER_INIT_BUDGET_MS = 200; @@ -316,6 +316,8 @@ export class PresentationEditor extends EventEmitter { // Remote cursor/presence state management /** Manager for remote cursor rendering and awareness subscriptions */ #remoteCursorManager: RemoteCursorManager | null = null; + /** Debounce timer for local cursor awareness updates (avoids ~190ms Liveblocks overhead per keystroke) */ + #cursorUpdateTimer: ReturnType | null = null; /** DOM element for rendering remote cursor overlays */ #remoteCursorOverlay: HTMLElement | null = null; /** DOM element for rendering local selection/caret (dual-layer overlay architecture) */ @@ -2134,6 +2136,12 @@ export class PresentationEditor extends EventEmitter { }, 'Layout RAF'); } + // Cancel pending cursor awareness update + if (this.#cursorUpdateTimer !== null) { + clearTimeout(this.#cursorUpdateTimer); + this.#cursorUpdateTimer = null; + } + // Clean up remote cursor manager if (this.#remoteCursorManager) { safeCleanup(() => { @@ -2350,16 +2358,18 @@ export class PresentationEditor extends EventEmitter { * @private */ #updateLocalAwarenessCursor(): void { - this.#remoteCursorManager?.updateLocalCursor(this.#editor?.state ?? null); - } - - /** - * Schedule a remote cursor re-render without re-normalizing awareness states. - * Delegates to RemoteCursorManager. - * @private - */ - #scheduleRemoteCursorReRender() { - this.#remoteCursorManager?.scheduleReRender(); + // Debounce awareness cursor updates to avoid per-keystroke overhead. + // Collaboration providers (e.g. Liveblocks) can spend ~190ms encoding and + // syncing awareness state per setLocalStateField call. Batching rapid + // cursor movements into a single update every 100ms keeps typing responsive + // while maintaining real-time cursor sharing for other participants. + if (this.#cursorUpdateTimer !== null) { + clearTimeout(this.#cursorUpdateTimer); + } + this.#cursorUpdateTimer = setTimeout(() => { + this.#cursorUpdateTimer = null; + this.#remoteCursorManager?.updateLocalCursor(this.#editor?.state ?? null); + }, 100); } /** @@ -3150,11 +3160,13 @@ export class PresentationEditor extends EventEmitter { this.#selectionSync.requestRender({ immediate: true }); - // Trigger cursor re-rendering on layout changes without re-normalizing awareness - // Layout reflow requires repositioning cursors in the DOM, but awareness states haven't changed - // This optimization avoids expensive Yjs position conversions on every layout update + // Re-normalize remote cursor positions after layout completes. + // Local document changes shift absolute positions, so Yjs relative positions + // must be re-resolved against the updated editor state. Without this, + // remote cursors appear offset by the number of characters the local user typed. if (this.#remoteCursorManager?.hasRemoteCursors()) { - this.#scheduleRemoteCursorReRender(); + this.#remoteCursorManager.markDirty(); + this.#remoteCursorManager.scheduleUpdate(); } } finally { if (!layoutCompleted) { diff --git a/packages/super-editor/src/extensions/block-node/block-node.js b/packages/super-editor/src/extensions/block-node/block-node.js index d6777ed6e2..e0ff366621 100644 --- a/packages/super-editor/src/extensions/block-node/block-node.js +++ b/packages/super-editor/src/extensions/block-node/block-node.js @@ -402,9 +402,11 @@ export const BlockNode = Extension.create({ } } - if (changed && !hasInitialized) { + if (!hasInitialized) { hasInitialized = true; - tr.setMeta('blockNodeInitialUpdate', true); + if (changed) { + tr.setMeta('blockNodeInitialUpdate', true); + } } // Restore marks since setNodeMarkup resets them diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index cd3d79a9c3..1b061f6bca 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -8,6 +8,10 @@ export const CollaborationPluginKey = new PluginKey('collaboration'); const headlessBindingStateByEditor = new WeakMap(); const headlessCleanupRegisteredEditors = new WeakSet(); +// Store Y.js observer references outside of reactive `this.options` to avoid +// Vue's deep traverse hitting circular references inside Y.js Map internals. +const collaborationCleanupByEditor = new WeakMap(); + const registerHeadlessBindingCleanup = (editor, cleanup) => { if (!cleanup || headlessCleanupRegisteredEditors.has(editor)) return; @@ -37,24 +41,25 @@ export const Collaboration = Extension.create({ this.options.ydoc = this.editor.options.ydoc; initSyncListener(this.options.ydoc, this.editor, this); - initDocumentListener({ ydoc: this.options.ydoc, editor: this.editor }); + const documentListenerCleanup = initDocumentListener({ ydoc: this.options.ydoc, editor: this.editor }); const [syncPlugin, fragment] = createSyncPlugin(this.options.ydoc, this.editor); this.options.fragment = fragment; const metaMap = this.options.ydoc.getMap('media'); - metaMap.observe((event) => { + const metaMapObserver = (event) => { event.changes.keys.forEach((_, key) => { if (!(key in this.editor.storage.image.media)) { const fileData = metaMap.get(key); this.editor.storage.image.media[key] = fileData; } }); - }); + }; + metaMap.observe(metaMapObserver); // Observer for remote header/footer JSON changes const headerFooterMap = this.options.ydoc.getMap('headerFooterJson'); - headerFooterMap.observe((event) => { + const headerFooterMapObserver = (event) => { // Only process remote changes (not our own) if (event.transaction.local) return; @@ -66,6 +71,17 @@ export const Collaboration = Extension.create({ } } }); + }; + headerFooterMap.observe(headerFooterMapObserver); + + // Store cleanup references in a non-reactive WeakMap (NOT this.options) + // to avoid Vue's deep traverse hitting circular references in Y.js Maps. + collaborationCleanupByEditor.set(this.editor, { + metaMap, + metaMapObserver, + headerFooterMap, + headerFooterMapObserver, + documentListenerCleanup, }); // Headless editors don't create an EditorView, so wire Y.js binding lifecycle here. @@ -86,6 +102,20 @@ export const Collaboration = Extension.create({ } }, + onDestroy() { + const cleanup = collaborationCleanupByEditor.get(this.editor); + if (!cleanup) return; + + // Clean up Y.js map observers to prevent memory leaks + cleanup.metaMap.unobserve(cleanup.metaMapObserver); + cleanup.headerFooterMap.unobserve(cleanup.headerFooterMapObserver); + + // Clean up ydoc afterTransaction listener and debounce timer + cleanup.documentListenerCleanup(); + + collaborationCleanupByEditor.delete(this.editor); + }, + addCommands() { return { addImageToCollaboration: @@ -138,22 +168,35 @@ const initDocumentListener = ({ ydoc, editor }) => { updateYdocDocxData(editor); }, 1000); - ydoc.on('afterTransaction', (transaction) => { + const afterTransactionHandler = (transaction) => { const { local } = transaction; const hasChangedDocx = checkDocxChanged(transaction); if (!hasChangedDocx && transaction.changed?.size && local) { debouncedUpdate(editor); } - }); + }; + + ydoc.on('afterTransaction', afterTransactionHandler); + + // Return cleanup function + return () => { + ydoc.off('afterTransaction', afterTransactionHandler); + debouncedUpdate.cancel(); + }; }; const debounce = (fn, wait) => { let timeout = null; - return (...args) => { + const debounced = (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), wait); + }; + debounced.cancel = () => { clearTimeout(timeout); - timeout = setTimeout(() => fn.apply(this, args), wait); + timeout = null; }; + return debounced; }; const initSyncListener = (ydoc, editor, extension) => { diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 2e28c1f0f1..00f5bcbece 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { h, defineComponent, ref, reactive, nextTick } from 'vue'; import { DOCX } from '@superdoc/common'; @@ -366,6 +366,13 @@ describe('SuperDoc.vue', () => { useSelectedTextMock.mockClear(); mockState.instances.clear(); + // Make RAF synchronous in tests — jsdom has no rendering loop, and + // SuperDoc.vue defers selection updates via requestAnimationFrame. + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => { + cb(Date.now()); + return 0; + }); + // Set up default mock presentation editor instances for common document IDs const mockPresentationEditor = { getSelectionBounds: vi.fn(() => null), @@ -389,6 +396,10 @@ describe('SuperDoc.vue', () => { } }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('wires editor lifecycle events and propagates updates', async () => { const superdocStub = createSuperdocStub(); const wrapper = await mountComponent(superdocStub); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 379f415302..892202f08c 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -279,6 +279,7 @@ const onEditorUpdate = ({ editor }) => { proxy.$superdoc.emit('editor-update', { editor }); }; +let selectionUpdateRafId = null; const onEditorSelectionChange = ({ editor, transaction }) => { if (skipSelectionUpdate.value) { // When comment is added selection will be equal to comment text @@ -295,6 +296,21 @@ const onEditorSelectionChange = ({ editor, transaction }) => { return; } + // Defer selection-related Vue reactive updates to the next animation frame. + // Without this, each PM transaction synchronously mutates reactive refs (selectionPosition, + // activeSelection, toolsMenuPosition), which triggers Vue's flushJobs microtask to re-evaluate + // hundreds of components — blocking the main thread for ~300ms per keystroke. + // RAF batches this work with the layout pipeline rerender, keeping typing responsive. + if (selectionUpdateRafId != null) { + cancelAnimationFrame(selectionUpdateRafId); + } + selectionUpdateRafId = requestAnimationFrame(() => { + selectionUpdateRafId = null; + processSelectionChange(editor, transaction); + }); +}; + +const processSelectionChange = (editor, transaction) => { const { documentId } = editor.options; const txnSelection = transaction?.selection; const stateSelection = editor.state?.selection ?? editor.view?.state?.selection; @@ -677,6 +693,10 @@ onMounted(() => { onBeforeUnmount(() => { document.removeEventListener('mousedown', handleDocumentMouseDown); + if (selectionUpdateRafId != null) { + cancelAnimationFrame(selectionUpdateRafId); + selectionUpdateRafId = null; + } }); const selectionLayer = ref(null); diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 770a7f59cf..14950b599f 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -2,6 +2,7 @@ import '../style.css'; import { EventEmitter } from 'eventemitter3'; import { v4 as uuidv4 } from 'uuid'; +import { markRaw } from 'vue'; import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; import { DOCX, PDF, HTML } from '@superdoc/common'; @@ -459,8 +460,19 @@ export class SuperDoc extends EventEmitter { if (externalYdoc && externalProvider) { // Use external provider - wire up awareness for SuperDoc events - this.ydoc = externalYdoc; - this.provider = externalProvider; + // Mark Y.js objects as raw to prevent Vue's deep reactive traversal + // from hitting circular references inside Y.js internals (causes stack overflow). + this.ydoc = markRaw(externalYdoc); + this.provider = markRaw(externalProvider); + + // Assign a stable color to the local user so awareness broadcasts it. + // Without this, y-prosemirror's cursor plugin mutates user.color to '#ffa500' + // (orange) as a default, causing color flickering between that default and + // the fallback colors used by RemoteCursorAwareness. + if (!this.config.user.color) { + this.config.user.color = this.colors[0] || '#4ECDC4'; + } + setupAwarenessHandler(externalProvider, this, this.config.user); // If no documents provided, create a default blank document @@ -501,11 +513,11 @@ export class SuperDoc extends EventEmitter { // Optionally, initialize separate superdoc sync - for comments, view, etc. if (commentsConfig.useInternalExternalComments && !commentsConfig.suppressInternalExternalComments) { const { ydoc: sdYdoc, provider: sdProvider } = initSuperdocYdoc(this); - this.ydoc = sdYdoc; - this.provider = sdProvider; + this.ydoc = markRaw(sdYdoc); + this.provider = markRaw(sdProvider); } else { - this.ydoc = processedDocuments[0].ydoc; - this.provider = processedDocuments[0].provider; + this.ydoc = markRaw(processedDocuments[0].ydoc); + this.provider = markRaw(processedDocuments[0].provider); } // Initialize comments sync, if enabled From ef4d5860624ce6b30bcc70aae338d8cb6920af94 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 15 Feb 2026 20:28:58 -0300 Subject: [PATCH 2/2] fix(collaboration): deduplicate yjs to prevent Liveblocks 1011 errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Liveblocks example aliases superdoc to the local dist build. Since y-prosemirror is bundled into superdoc's ES chunks (not externalized), its `import "yjs"` resolves from packages/superdoc/node_modules — a different physical copy than the example's own node_modules/yjs. Two copies of yjs breaks Y.js constructor instanceof checks, producing invalid CRDT operations that Liveblocks rejects with WebSocket code 1011. Adding resolve.dedupe forces Vite to resolve all yjs imports from a single location regardless of the importer's filesystem position. --- examples/collaboration/liveblocks/vite.config.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/collaboration/liveblocks/vite.config.js b/examples/collaboration/liveblocks/vite.config.js index 563e7ae2b9..6995298e2c 100644 --- a/examples/collaboration/liveblocks/vite.config.js +++ b/examples/collaboration/liveblocks/vite.config.js @@ -11,6 +11,12 @@ export default defineConfig({ 'superdoc/style.css': path.join(superdocPkg, 'dist/style.css'), superdoc: path.join(superdocPkg, 'dist/superdoc.es.js'), }, + // Force a single copy of yjs. Without this, Vite resolves `import "yjs"` + // from superdoc's dist chunks to the monorepo's copy, while the example + // app resolves to its own node_modules copy — two physical files of the + // same version. Y.js detects this and prints "Yjs was already imported", + // breaking instanceof checks and corrupting Liveblocks rooms (code 1011). + dedupe: ['yjs'], }, server: { port: 3000,