Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 56 additions & 14 deletions examples/collaboration/liveblocks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [synced, setSynced] = useState(false);

useEffect(() => {
if (!PUBLIC_KEY) return;
Expand All @@ -20,44 +30,76 @@ 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 <div style={{ padding: '2rem' }}>Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env</div>;
return <div style={missingKeyStyle}>Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env</div>;
}

return (
<div className='app'>
<header>
<h1>SuperDoc + Liveblocks</h1>
<div className='users'>
{users.map((u, i) => (
<span key={i} className='user' style={{ background: u.user?.color || '#666' }}>
{u.user?.name}
{users.map((u) => (
<span key={u.clientId} className='user' style={{ background: u.color || '#666' }}>
{u.name || u.email}
</span>
))}
</div>
</header>
<main>
{!synced && <div style={connectingStyle}>Connecting…</div>}
<div id='superdoc' className='superdoc-container' />
</main>
</div>
Expand Down
18 changes: 18 additions & 0 deletions examples/collaboration/liveblocks/vite.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
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'),
},
// 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,
fs: {
allow: [superdocPkg, '.'],
},
},
});
20 changes: 10 additions & 10 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -2123,6 +2124,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
}

const end = perfNow();

this.emit('transaction', {
editor: this,
transaction: transactionToApply,
Expand Down Expand Up @@ -2498,17 +2500,15 @@ export class Editor extends EventEmitter<EditorEventMap> {
* @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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
/** DOM element for rendering remote cursor overlays */
#remoteCursorOverlay: HTMLElement | null = null;
/** DOM element for rendering local selection/caret (dual-layer overlay architecture) */
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions packages/super-editor/src/extensions/block-node/block-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading