Skip to content

Commit 331d599

Browse files
committed
Merge branch 'main' into nick/sd-1937-contract-first-document-api-alpha
2 parents dc1c158 + 7c1a9d5 commit 331d599

18 files changed

Lines changed: 445 additions & 5269 deletions

File tree

devtools/visual-testing/pnpm-lock.yaml

Lines changed: 0 additions & 5088 deletions
This file was deleted.

examples/collaboration/liveblocks/src/App.tsx

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1-
import { useEffect, useRef, useState } from 'react';
21
import { createClient } from '@liveblocks/client';
32
import { LiveblocksYjsProvider } from '@liveblocks/yjs';
4-
import * as Y from 'yjs';
5-
import 'superdoc/style.css';
3+
import { CSSProperties, useEffect, useRef, useState } from 'react';
64
import { SuperDoc } from 'superdoc';
5+
import 'superdoc/style.css';
6+
import * as Y from 'yjs';
77

88
const PUBLIC_KEY = import.meta.env.VITE_LIVEBLOCKS_PUBLIC_KEY as string;
99
const ROOM_ID = (import.meta.env.VITE_ROOM_ID as string) || 'superdoc-room';
1010

11-
export default function App() {
11+
// ---------------------------------------------------------------------------
12+
// Hook: useSuperdocCollaboration
13+
// ---------------------------------------------------------------------------
14+
15+
interface CollaborationState {
16+
users: any[];
17+
synced: boolean;
18+
}
19+
20+
function useSuperdocCollaboration(userName: string): CollaborationState {
1221
const superdocRef = useRef<any>(null);
1322
const [users, setUsers] = useState<any[]>([]);
23+
const [synced, setSynced] = useState(false);
1424

1525
useEffect(() => {
1626
if (!PUBLIC_KEY) return;
@@ -20,44 +30,78 @@ export default function App() {
2030
const ydoc = new Y.Doc();
2131
const provider = new LiveblocksYjsProvider(room, ydoc);
2232

23-
provider.on('sync', (synced: boolean) => {
24-
if (!synced) return;
33+
provider.on('sync', (isSynced: boolean) => {
34+
if (!isSynced) return;
35+
// Guard: only create SuperDoc once. Liveblocks fires 'sync' again on
36+
// reconnect, which would create duplicate editors writing to the same
37+
// Y.js doc — corrupting the room state (code 1011).
38+
if (superdocRef.current) return;
39+
setSynced(true);
2540

2641
superdocRef.current = new SuperDoc({
2742
selector: '#superdoc',
2843
documentMode: 'editing',
29-
user: { name: `User ${Math.floor(Math.random() * 1000)}`, email: 'user@example.com' },
44+
user: { name: userName, email: `${userName.toLowerCase().replace(' ', '-')}@example.com` },
3045
modules: {
3146
collaboration: { ydoc, provider },
3247
},
33-
onAwarenessUpdate: ({ states }: any) => setUsers(states.filter((s: any) => s.user)),
48+
onAwarenessUpdate: ({ states }: any) => setUsers(states),
49+
onEditorCreate: ({ editor }: any) => {
50+
if (import.meta.env.DEV) {
51+
(window as any).editor = editor;
52+
}
53+
},
3454
});
3555
});
3656

3757
return () => {
3858
superdocRef.current?.destroy();
59+
superdocRef.current = null;
60+
setSynced(false);
3961
provider.destroy();
4062
leave();
4163
};
42-
}, []);
64+
}, [userName]);
65+
66+
return { users, synced };
67+
}
68+
69+
// ---------------------------------------------------------------------------
70+
// Component: App
71+
// ---------------------------------------------------------------------------
72+
73+
const connectingStyle: CSSProperties = {
74+
display: 'flex',
75+
alignItems: 'center',
76+
justifyContent: 'center',
77+
height: 200,
78+
color: '#888',
79+
};
80+
81+
const missingKeyStyle: CSSProperties = { padding: '2rem' };
82+
83+
export default function App() {
84+
const [userName] = useState(() => `User ${Math.floor(Math.random() * 1000)}`);
85+
const { users, synced } = useSuperdocCollaboration(userName);
4386

4487
if (!PUBLIC_KEY) {
45-
return <div style={{ padding: '2rem' }}>Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env</div>;
88+
return <div style={missingKeyStyle}>Add VITE_LIVEBLOCKS_PUBLIC_KEY to .env</div>;
4689
}
4790

4891
return (
4992
<div className='app'>
5093
<header>
5194
<h1>SuperDoc + Liveblocks</h1>
5295
<div className='users'>
53-
{users.map((u, i) => (
54-
<span key={i} className='user' style={{ background: u.user?.color || '#666' }}>
55-
{u.user?.name}
96+
{users.map((u) => (
97+
<span key={u.clientId} className='user' style={{ background: u.color || '#666' }}>
98+
{u.name || u.email}
5699
</span>
57100
))}
58101
</div>
59102
</header>
60103
<main>
104+
{!synced && <div style={connectingStyle}>Connecting…</div>}
61105
<div id='superdoc' className='superdoc-container' />
62106
</main>
63107
</div>

examples/collaboration/liveblocks/vite.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { defineConfig } from 'vite';
21
import react from '@vitejs/plugin-react';
2+
import { defineConfig } from 'vite';
33

44
export default defineConfig({
55
plugins: [react()],
6+
resolve: {
7+
// Ensure only one Y.js copy is bundled across the app and dependencies.
8+
dedupe: ['yjs'],
9+
},
610
server: {
711
port: 3000,
812
},

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EditorState, Transaction, Plugin } from 'prosemirror-state';
2+
import { Transform } from 'prosemirror-transform';
23
import type { EditorView as PmEditorView } from 'prosemirror-view';
34
import type { Node as PmNode, Schema } from 'prosemirror-model';
45
import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js';
@@ -2125,6 +2126,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
21252126
}
21262127

21272128
const end = perfNow();
2129+
21282130
this.emit('transaction', {
21292131
editor: this,
21302132
transaction: transactionToApply,
@@ -2500,17 +2502,15 @@ export class Editor extends EventEmitter<EditorEventMap> {
25002502
* @returns The updated document in JSON
25012503
*/
25022504
#prepareDocumentForExport(comments: Comment[] = []): ProseMirrorJSON {
2503-
const newState = PmEditorState.create({
2504-
schema: this.schema,
2505-
doc: this.state.doc,
2506-
plugins: this.state.plugins,
2507-
});
2508-
2509-
const { tr, doc } = newState;
2510-
2505+
// Use Transform directly instead of creating a throwaway EditorState.
2506+
// EditorState.create() calls Plugin.init() for every plugin, and
2507+
// yUndoPlugin.init() registers persistent observers on the shared ydoc
2508+
// that are never cleaned up — causing an observer leak that degrades
2509+
// collaboration performance over time.
2510+
const doc = this.state.doc;
2511+
const tr = new Transform(doc);
25112512
prepareCommentsForExport(doc, tr, this.schema, comments);
2512-
const updatedState = newState.apply(tr);
2513-
return updatedState.doc.toJSON();
2513+
return tr.doc.toJSON();
25142514
}
25152515

25162516
getUpdatedJson(): ProseMirrorJSON {

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

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ export class PresentationEditor extends EventEmitter {
316316
// Remote cursor/presence state management
317317
/** Manager for remote cursor rendering and awareness subscriptions */
318318
#remoteCursorManager: RemoteCursorManager | null = null;
319+
/** Debounce timer for local cursor awareness updates (avoids ~190ms Liveblocks overhead per keystroke) */
320+
#cursorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
319321
/** DOM element for rendering remote cursor overlays */
320322
#remoteCursorOverlay: HTMLElement | null = null;
321323
/** DOM element for rendering local selection/caret (dual-layer overlay architecture) */
@@ -463,7 +465,6 @@ export class PresentationEditor extends EventEmitter {
463465

464466
// Wire up manager callbacks to use PresentationEditor methods
465467
this.#remoteCursorManager.setUpdateCallback(() => this.#updateRemoteCursors());
466-
this.#remoteCursorManager.setReRenderCallback(() => this.#renderRemoteCursors());
467468

468469
this.#hoverOverlay = doc.createElement('div');
469470
this.#hoverOverlay.className = 'presentation-editor__hover-overlay';
@@ -2154,6 +2155,12 @@ export class PresentationEditor extends EventEmitter {
21542155
}, 'Layout RAF');
21552156
}
21562157

2158+
// Cancel pending cursor awareness update
2159+
if (this.#cursorUpdateTimer !== null) {
2160+
clearTimeout(this.#cursorUpdateTimer);
2161+
this.#cursorUpdateTimer = null;
2162+
}
2163+
21572164
// Clean up remote cursor manager
21582165
if (this.#remoteCursorManager) {
21592166
safeCleanup(() => {
@@ -2276,7 +2283,13 @@ export class PresentationEditor extends EventEmitter {
22762283
}
22772284
};
22782285
const handleSelection = () => {
2279-
this.#scheduleSelectionUpdate();
2286+
// Use immediate rendering for selection-only changes (clicks, arrow keys).
2287+
// Without immediate, the render is RAF-deferred — leaving a window where
2288+
// a remote collaborator's edit can cancel the pending render via
2289+
// setDocEpoch → cancelScheduledRender. Immediate rendering is safe here:
2290+
// if layout is updating (due to a concurrent doc change), flushNow()
2291+
// is a no-op and the render will be picked up after layout completes.
2292+
this.#scheduleSelectionUpdate({ immediate: true });
22802293
// Update local cursor in awareness for collaboration
22812294
// This bypasses y-prosemirror's focus check which may fail for hidden PM views
22822295
this.#updateLocalAwarenessCursor();
@@ -2370,16 +2383,18 @@ export class PresentationEditor extends EventEmitter {
23702383
* @private
23712384
*/
23722385
#updateLocalAwarenessCursor(): void {
2373-
this.#remoteCursorManager?.updateLocalCursor(this.#editor?.state ?? null);
2374-
}
2375-
2376-
/**
2377-
* Schedule a remote cursor re-render without re-normalizing awareness states.
2378-
* Delegates to RemoteCursorManager.
2379-
* @private
2380-
*/
2381-
#scheduleRemoteCursorReRender() {
2382-
this.#remoteCursorManager?.scheduleReRender();
2386+
// Debounce awareness cursor updates to avoid per-keystroke overhead.
2387+
// Collaboration providers (e.g. Liveblocks) can spend ~190ms encoding and
2388+
// syncing awareness state per setLocalStateField call. Batching rapid
2389+
// cursor movements into a single update every 100ms keeps typing responsive
2390+
// while maintaining real-time cursor sharing for other participants.
2391+
if (this.#cursorUpdateTimer !== null) {
2392+
clearTimeout(this.#cursorUpdateTimer);
2393+
}
2394+
this.#cursorUpdateTimer = setTimeout(() => {
2395+
this.#cursorUpdateTimer = null;
2396+
this.#remoteCursorManager?.updateLocalCursor(this.#editor?.state ?? null);
2397+
}, 100);
23832398
}
23842399

23852400
/**
@@ -3170,11 +3185,13 @@ export class PresentationEditor extends EventEmitter {
31703185

31713186
this.#selectionSync.requestRender({ immediate: true });
31723187

3173-
// Trigger cursor re-rendering on layout changes without re-normalizing awareness
3174-
// Layout reflow requires repositioning cursors in the DOM, but awareness states haven't changed
3175-
// This optimization avoids expensive Yjs position conversions on every layout update
3188+
// Re-normalize remote cursor positions after layout completes.
3189+
// Local document changes shift absolute positions, so Yjs relative positions
3190+
// must be re-resolved against the updated editor state. Without this,
3191+
// remote cursors appear offset by the number of characters the local user typed.
31763192
if (this.#remoteCursorManager?.hasRemoteCursors()) {
3177-
this.#scheduleRemoteCursorReRender();
3193+
this.#remoteCursorManager.markDirty();
3194+
this.#remoteCursorManager.scheduleUpdate();
31783195
}
31793196
} finally {
31803197
if (!layoutCompleted) {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type { RemoteCursorState } from '../types.js';
1010
*/
1111
type AwarenessLike = {
1212
clientID?: number;
13+
/** Liveblocks and some providers expose clientID on the underlying Y.Doc instead */
14+
doc?: { clientID?: number };
1315
getStates?: () => Map<number, unknown>;
1416
};
1517

@@ -63,9 +65,13 @@ export function normalizeAwarenessStates(options: {
6365
const states = provider.awareness?.getStates?.();
6466
const normalized = new Map<number, RemoteCursorState>();
6567

68+
// Resolve local client ID — standard Yjs awareness exposes it as awareness.clientID,
69+
// but some providers (e.g. Liveblocks) only expose it on the underlying Y.Doc.
70+
const localClientId = provider.awareness?.clientID ?? provider.awareness?.doc?.clientID;
71+
6672
states?.forEach((aw, clientId) => {
6773
// Skip local client
68-
if (clientId === provider.awareness?.clientID) return;
74+
if (localClientId != null && clientId === localClientId) return;
6975

7076
// Type assertion for awareness state properties
7177
const awState = aw as {

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

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -318,35 +318,6 @@ export class RemoteCursorManager {
318318
this.#pendingUpdateCallback = callback;
319319
}
320320

321-
/**
322-
* Schedule a remote cursor re-render without re-normalizing awareness states.
323-
* Performance optimization: avoids expensive Yjs position conversions on layout changes.
324-
* Used when layout geometry changes but cursor positions haven't (e.g., zoom, scroll, reflow).
325-
*/
326-
scheduleReRender(): void {
327-
if (this.#options.presence?.enabled === false) return;
328-
if (this.#remoteCursorUpdateScheduled) return;
329-
this.#remoteCursorUpdateScheduled = true;
330-
331-
// Use RAF for re-renders since they're triggered by layout/scroll events
332-
const win = this.#options.visibleHost.ownerDocument?.defaultView ?? window;
333-
win.requestAnimationFrame(() => {
334-
this.#remoteCursorUpdateScheduled = false;
335-
this.#lastRemoteCursorRenderTime = performance.now();
336-
this.#pendingReRenderCallback?.();
337-
});
338-
}
339-
340-
/** Callback to invoke when scheduled re-render fires */
341-
#pendingReRenderCallback: (() => void) | null = null;
342-
343-
/**
344-
* Set the callback to invoke when a scheduled re-render fires.
345-
*/
346-
setReRenderCallback(callback: (() => void) | null): void {
347-
this.#pendingReRenderCallback = callback;
348-
}
349-
350321
/**
351322
* Update remote cursor state by normalizing awareness states and rendering.
352323
* Call this when awareness state has changed.
@@ -516,7 +487,6 @@ export class RemoteCursorManager {
516487

517488
// Clear callbacks
518489
this.#pendingUpdateCallback = null;
519-
this.#pendingReRenderCallback = null;
520490
this.#onTelemetry = null;
521491
this.#onCursorsUpdate = null;
522492

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2266,7 +2266,7 @@ describe('PresentationEditor', () => {
22662266

22672267
describe('Selection update mechanisms', () => {
22682268
describe('#scheduleSelectionUpdate race condition guards', () => {
2269-
it('should skip scheduling when already scheduled', async () => {
2269+
it('should render synchronously with immediate mode when safe', async () => {
22702270
const layoutResult = {
22712271
layout: { pages: [] },
22722272
measures: [],
@@ -2294,12 +2294,13 @@ describe('PresentationEditor', () => {
22942294
expect(selectionUpdateCall).toBeDefined();
22952295
const handleSelection = selectionUpdateCall![1] as () => void;
22962296

2297-
// Call twice - should only schedule once
2297+
// Call twice - with immediate mode, renders synchronously when safe
2298+
// so no RAF scheduling is needed
22982299
handleSelection();
22992300
handleSelection();
23002301

2301-
// Should only call requestAnimationFrame once (second call is deduplicated)
2302-
expect(rafSpy).toHaveBeenCalledTimes(1);
2302+
// Should NOT use RAF because immediate rendering handles it synchronously
2303+
expect(rafSpy).not.toHaveBeenCalled();
23032304

23042305
rafSpy.mockRestore();
23052306
});
@@ -2388,7 +2389,7 @@ describe('PresentationEditor', () => {
23882389
rafSpy.mockRestore();
23892390
});
23902391

2391-
it('should successfully schedule when no guards are active', async () => {
2392+
it('should render synchronously when no guards are active', async () => {
23922393
const layoutResult = {
23932394
layout: { pages: [] },
23942395
measures: [],
@@ -2417,11 +2418,12 @@ describe('PresentationEditor', () => {
24172418
// Clear RAF spy to track new calls
24182419
rafSpy.mockClear();
24192420

2420-
// Schedule selection update with no guards active
2421+
// Selection update with no guards active — renders synchronously via
2422+
// immediate mode, bypassing RAF
24212423
handleSelection();
24222424

2423-
// Should schedule RAF successfully
2424-
expect(rafSpy).toHaveBeenCalledTimes(1);
2425+
// Should NOT use RAF because immediate rendering handles it synchronously
2426+
expect(rafSpy).not.toHaveBeenCalled();
24252427

24262428
rafSpy.mockRestore();
24272429
});

0 commit comments

Comments
 (0)