fix(collaboration): memory leaks, Vue stack overflow, and Liveblocks stability (SD-1924)#2030
Conversation
cce2b91 to
cf29539
Compare
There was a problem hiding this comment.
Pull request overview
This PR addresses multiple collaboration-related stability and performance issues in SuperDoc/Super Editor (Y.js observer leaks, Vue reactivity stack overflow, cursor awareness overhead, and Liveblocks reconnect behavior), plus a few example-app fixes.
Changes:
- Add cleanup paths for Y.js observers/listeners and avoid plugin-init observer leaks during export.
- Reduce UI/perf regressions by marking Y.js objects as non-reactive, deferring selection updates to
requestAnimationFrame, and debouncing local awareness cursor updates. - Fix repeated initialization/traversal behaviors and improve the Liveblocks example app reliability/config.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/superdoc/src/core/SuperDoc.js | Uses markRaw() for Y.js objects; assigns stable local user color for awareness. |
| packages/superdoc/src/SuperDoc.vue | Defers selection reactive updates via RAF; cancels RAF on unmount. |
| packages/superdoc/src/SuperDoc.test.js | Makes RAF synchronous in tests and restores mocks after each test. |
| packages/super-editor/src/extensions/collaboration/collaboration.js | Tracks Y.js observers/handlers and adds onDestroy() cleanup; debounce supports .cancel(). |
| packages/super-editor/src/extensions/block-node/block-node.js | Ensures initialization traversal only happens once regardless of detected changes. |
| packages/super-editor/src/core/presentation-editor/PresentationEditor.ts | Debounces local awareness cursor updates; updates remote cursor refresh strategy after layout. |
| packages/super-editor/src/core/Editor.ts | Uses Transform directly for export prep to avoid plugin init/leaks. |
| examples/collaboration/liveblocks/vite.config.js | Adds local alias + fs allow-list for resolving built superdoc assets. |
| examples/collaboration/liveblocks/src/App.tsx | Prevents duplicate SuperDoc creation on reconnect; fixes awareness state rendering and adds “Connecting…” UI. |
Comments suppressed due to low confidence (1)
packages/superdoc/src/SuperDoc.vue:287
- When returning early (e.g.,
skipSelectionUpdateor viewing mode), any previously scheduledrequestAnimationFramecallback is left pending and can still callprocessSelectionChange, re-applying selection state after it was intentionally skipped/reset. CancelselectionUpdateRafIdat the start of this handler (before the early-return branches) so stale selection updates can’t run.
const onEditorSelectionChange = ({ editor, transaction }) => {
if (skipSelectionUpdate.value) {
// When comment is added selection will be equal to comment text
// Should skip calculations to keep text selection for comments correct
skipSelectionUpdate.value = false;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cce2b919a8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…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
2d8a9d9 to
0c45bcb
Compare
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.
packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
Show resolved
Hide resolved
packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
Outdated
Show resolved
Hide resolved
…rder Three fixes for Liveblocks 1011 connection errors: 1. Fix destroy order: unmount app (editors) BEFORE destroying ydoc/provider. Previously, #cleanupCollaboration() destroyed the ydoc while editors were still alive — pending debounced writes could fire against a destroyed ydoc, corrupting the room state. Now editors are destroyed first, triggering each extension's onDestroy() which cancels timers and unobserves Y.js maps. 2. Reduce DOCX sync debounce from 1s to 30s. The actual document content syncs in real-time via y-prosemirror's XmlFragment. The DOCX blob in the Y.Map is only supplementary data for new joiners' converter setup. Writing it every 1s generates large Y.js updates (full DOCX XML serialization) that accumulate as Y.Map tombstones, gradually growing the room's stored data until Liveblocks rejects connections. 3. Add ydoc.isDestroyed guards in updateYdocDocxData and pushHeaderFooterToYjs to prevent writes to a destroyed ydoc. Also re-check after the async exportDocx call since the ydoc may have been destroyed mid-export. 4. Force single yjs copy via Vite alias instead of resolve.dedupe (which doesn't work for files outside the project root).
- Fix stale transaction in RAF: capture only editor, not transaction, in the selection change RAF callback since ProseMirror may process more keystrokes before RAF fires - Cancel pending RAF before early returns to prevent stale callbacks from repopulating selection state after mode switches - Use hash-based color assignment so different users get different cursor colors from the palette instead of all getting colors[0] - Change perfLog from console.warn to console.log since these are debug metrics, not warnings - Remove dead scheduleReRender/setReRenderCallback code from RemoteCursorManager (never invoked) - Gate window.editor assignment behind import.meta.env.DEV
y-prosemirror's cursor plugin only supports hex color format. The previous approach using HSL caused "unsupported color format" warnings and broken cursor rendering. Replace with a 24-color hex palette (down from HSL's 360 hues but still reduces collision probability to ~4% vs 12.5% with 8 colors). Also fix awarenessStatesToArray to prefer the user's pre-assigned color from awareness state instead of overriding with the palette color (which was undefined when config.colors was empty).
Changed the default ROOM_ID from 'superdoc-collab-v8' to 'superdoc-room' to align with updated naming conventions in the Liveblocks collaboration example.
The Liveblocks awareness object exposes clientID on awareness.doc.clientID instead of awareness.clientID (standard Yjs). This caused the local client filter in normalizeAwarenessStates to fail (clientID was undefined), so the user saw their own remote cursor label — which updated with 100ms debounce lag, creating a stale/mispositioned cursor overlay. Fix: Fall back to awareness.doc?.clientID when awareness.clientID is undefined. Also use immediate rendering for selection updates to reduce the race window where remote edits can cancel pending selection renders.
…mple - Reintroduced the import of defineConfig in vite.config.js for proper configuration. - Removed outdated aliases for superdoc/style.css and superdoc in Vite config. - Updated default ROOM_ID from 'superdoc-collab-v8' to 'superdoc-room' to align with naming conventions.
Via L3 deep analysis · critical risk |
blockNodePlugin's appendTransaction was incrementing sdBlockRev on every doc change, including Y.js-origin transactions from remote collaborators. This created an infinite feedback loop in collaboration: Tab A increments rev → syncs to Y.js → Tab B receives, increments rev → syncs back → Tab A increments again → forever. The fix checks ySyncPluginKey meta for isChangeOrigin and skips the sdBlockRev increment for Y.js-origin transactions. sdBlockId dedup still runs for all transactions to prevent split-related duplicates. Also restores superdoc dist aliases in the Liveblocks example Vite config, which are needed for the example to resolve the built package.
Visual diffs detectedPixel differences were found in visual tests. This is not blocking — reproduce locally with |
…d RAF selection in viewing mode
|
🎉 This PR is included in superdoc v1.15.0-next.4 The release is available on GitHub release |
# [1.15.0](v1.14.0...v1.15.0) (2026-02-20) ### Bug Fixes * **ai-actions:** preserve html/markdown insertion and prevent repeated formatted replacement ([#2117](#2117)) ([9f685e9](9f685e9)) * **ai:** support headless mode in EditorAdapter.applyPatch ([#1859](#1859)) ([cf9275d](cf9275d)) * **collaboration:** memory leaks, Vue stack overflow, and Liveblocks stability (SD-1924) ([#2030](#2030)) ([a6827fd](a6827fd)), closes [#prepareDocumentForExport](https://github.com/superdoc-dev/superdoc/issues/prepareDocumentForExport) * **collab:** prevent stale view when remote Y.js changes bypass sdBlockRev increment ([#2099](#2099)) ([0895a93](0895a93)) * **converter:** handle null list lvlText and always clear numbering cache ([#2113](#2113)) ([336958c](336958c)) * **document-api:** remove search match cap and validate moveComment bounds ([6d3de67](6d3de67)) * export docx blobs with docx mime type ([#1849](#1849)) ([1bc466d](1bc466d)) * **export:** prevent DOCX corruption from entity encoding and orphaned delInstrText (SD-1943) ([#2102](#2102)) ([56e917f](56e917f)), closes [#replaceSpecialCharacters](https://github.com/superdoc-dev/superdoc/issues/replaceSpecialCharacters) [#1988](#1988) * **layout-bridge:** correct cell selection for tables with rowspan ([#1839](#1839)) ([0b782be](0b782be)) * **layout,converter:** text box rendering and page-relative anchor positioning (SD-1331, SD-1838) ([#2034](#2034)) ([3947f39](3947f39)) * **layout:** route list text-start calculations through resolveListTextStartPx ([02b14b8](02b14b8)) * **painter-dom:** use absolute page Y for page-relative anchors in header/footer decorations ([0b9bc72](0b9bc72)) * preserve selection highlight when opening toolbar dropdowns ([#2097](#2097)) ([a33568e](a33568e)) * structured content renders correct on hover and select ([#1843](#1843)) ([dab3f04](dab3f04)) * **super-editor:** add unsupported-content reporting across HTML/Markdown import paths ([#2115](#2115)) ([84880b7](84880b7)) * **super-editor:** handle partial comment file-sets and clean up stale parts on export ([#2123](#2123)) ([f63ae0a](f63ae0a)) * **super-editor:** restore <hr> contentBlock parsing and harden VML HR export fallback ([#2118](#2118)) ([da51b1f](da51b1f)) * table headers are incorrectly imported from html ([#2112](#2112)) ([e8d1480](e8d1480)) * table resizing regression ([#2091](#2091)) ([20ed24e](20ed24e)) * table resizing regression ([#2091](#2091)) ([9a07f1c](9a07f1c)) * **tables:** align tableHeader attrs with tableCell to fix oversized DOCX export widths ([#2114](#2114)) ([38f0430](38f0430)) * **tables:** fix autofit column scaling, cell width overflow, and page break splitting ([#1987](#1987)) ([61a3f6f](61a3f6f)) * **tables:** prevent tblInd double-shrink when using tblGrid widths (SD-1494) ([8750ece](8750ece)) * track changes comment text for formatting changes ([#2013](#2013)) ([b2a43ff](b2a43ff)) * wire DocumentApi to Editor.doc with lifecycle-safe caching ([57326ea](57326ea)) ### Features * cropped images ([#1940](#1940)) ([3767a49](3767a49)) * extend document-api with format, examples, create.heading ([#2092](#2092)) ([fdf8c7c](fdf8c7c)) * **lists:** support hidden list indicators via w:vanish ([#2069](#2069)) ([#2080](#2080)) ([0bed0fd](0bed0fd)) * the document API limited alpha ([#2087](#2087)) ([091c24c](091c24c))
|
🎉 This PR is included in superdoc v1.15.0 The release is available on GitHub release |
Closes IT-474
CleanShot.2026-02-15.at.19.56.51.mp4
Summary
Fixes multiple collaboration bugs causing typing lag, room corruption, Vue stack overflow crashes, and user color flickering when using external providers (Liveblocks).
How SuperDoc Collaboration Works
SuperDoc collaboration uses Y.js (a CRDT library) to synchronize document state between multiple users. Here's the step-by-step flow:
1. Initialization
When a user opens a collaborative document, SuperDoc receives a Y.js
Docand a provider (e.g. Liveblocks, Hocuspocus) through themodules.collaborationconfig option:2. Real-time Editing Sync
When a user types, changes flow through two parallel sync paths:
3. Cursor Awareness
Each user's cursor position is shared via the Y.js awareness protocol:
4. Vue Rendering Bridge
SuperDoc uses dual rendering (hidden ProseMirror + visible DomPainter). Vue manages the toolbar and UI state:
What Was Broken and How Each Fix Addresses It
Fix 1: Y.js Observer Memory Leaks (
collaboration.js)Problem: The collaboration extension registered 4 observers/listeners without cleanup:
metaMap.observe()— media file syncheaderFooterMap.observe()— header/footer syncydoc.on('afterTransaction')— DOCX XML syncWhen editors were destroyed and recreated (HMR, route changes, document switches), these accumulated. Each leaked
afterTransactionhandler ran a full DOCX export on every Y.js transaction.Fix: Added
onDestroy()lifecycle hook. Observer references are stored in a module-levelWeakMap<Editor, CleanupData>(not in reactivethis.options) and properly cleaned up on editor destruction. The debounce utility now supports.cancel().Fix 2: yUndoPlugin Observer Leak (
Editor.ts)Problem:
#prepareDocumentForExportcreated a throwawayEditorStateto transform the document for DOCX export.EditorState.create()callsPlugin.init()for every plugin, andyUndoPlugin.init()registers a persistentY.UndoManagerobserver on the shared ydoc. These observers were never cleaned up because the throwaway state was immediately discarded.Fix: Use
new Transform(doc)directly instead ofEditorState.create(). All methods used byprepareCommentsForExport(removeMark,insert,addMark,setNodeMarkup,delete,mapping.map) areTransformmethods — noTransaction-specific APIs are needed.Fix 3: Vue
traverseStack Overflow (SuperDoc.js)Problem:
SuperDoc.jsstoresthis.ydocandthis.provideron the instance. The instance is exposed to Vue as a global property ($superdoc). Vue's reactivity system deep-traverses all properties to make them reactive. Y.js objects have deep circular internal references (_item→parent→doc→_store→ items → ...) that cause infinite recursion →RangeError: Maximum call stack size exceeded.Fix: Wrap all Y.js object assignments with
markRaw()from Vue. This adds a__v_skipflag that tells Vue to never traverse the object. Applied to all 4 assignment paths (external provider, internal single-doc, internal multi-doc, internal superdoc sync).Fix 4: User Color Flickering (
SuperDoc.js)Problem: Three competing color systems with no coordination:
yCursorPluginmutatesuser.color = '#ffa500'(orange) when no color is set in awareness stateRemoteCursorAwarenessusesgetFallbackCursorColor(clientId)which assigns from a paletteawarenessStatesToArrayassigns from a shuffled palette viauserColorMapThe external provider path in
SuperDoc.jsnever setuser.colorbefore broadcasting awareness, so each system kept overwriting with different colors every render cycle.Fix: Set
this.config.user.color = this.colors[0] || '#4ECDC4'before callingsetupAwarenessHandler, ensuring awareness state always has a stable color.Fix 5: Liveblocks Room Corruption (
App.tsx)Problem:
provider.on('sync')fires not only on initial connection but also on every reconnect. The original example code created a newSuperDocinstance on every sync event, resulting in duplicate editors writing to the same Y.js document — causing conflicting CRDT operations that permanently corrupted the Liveblocks room state (WebSocket code 1011).Fix: Guard with
if (superdocRef.current) returnto ensure SuperDoc is only created once per component lifecycle.Fix 6: Typing Lag — Cursor Awareness Overhead (
PresentationEditor.ts)Problem: Every keystroke triggered
#updateLocalAwarenessCursor()synchronously, which callsawareness.setLocalStateField(). With Liveblocks, each call takes ~190ms to encode and sync awareness state over WebSocket.Fix: Debounce cursor awareness updates to 100ms. Rapid keystrokes batch into a single update, keeping typing responsive while maintaining real-time cursor sharing.
Fix 7: Typing Lag — Vue flushJobs Blocking (
SuperDoc.vue)Problem: Each ProseMirror transaction synchronously updated Vue reactive refs (
selectionPosition,activeSelection,toolsMenuPosition). Each mutation triggered Vue'sflushJobsmicrotask, which re-evaluated hundreds of components — blocking the main thread for ~300ms per keystroke.Fix: Defer selection state updates to
requestAnimationFrame. RAF fires before the next paint, so the toolbar still reflects correct state by the time the user sees the rendered frame. Pending RAFs are cancelled on new transactions and on component unmount.Fix 8: Repeated Full-Document Traversals (
block-node.js)Problem: The
hasInitializedflag was only set totruewhen changes were detected. If the initial document had all validsdBlockIdvalues, the initialization traversal ran on every single transaction — potentially thousands of wasteful full-document walks.Fix: Set
hasInitialized = trueunconditionally after the firstappendTransactioncall. TheblockNodeInitialUpdatemeta is only set when actual changes were made.Fix 9: Liveblocks Example App (
App.tsx,vite.config.js)Problem: Multiple issues in the example app:
states.filter((s) => s.user)filtered ALL users becauseawarenessStatesToArrayreturns flat objects (no nested.user)u.user?.colorinstead ofu.colorFix: Extracted
useSuperdocCollaborationcustom hook, corrected property access to flat objects, stableclientIdkeys, proper cleanup for Strict Mode, hoisted static styles, added Vite alias config.Test plan
pnpm test— 810+ tests across packages)