Skip to content
Merged
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
5,088 changes: 0 additions & 5,088 deletions devtools/visual-testing/pnpm-lock.yaml

This file was deleted.

70 changes: 57 additions & 13 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';

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,78 @@ 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) => {
if (import.meta.env.DEV) {
(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
6 changes: 5 additions & 1 deletion examples/collaboration/liveblocks/vite.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [react()],
resolve: {
// Ensure only one Y.js copy is bundled across the app and dependencies.
dedupe: ['yjs'],
},
server: {
port: 3000,
},
Expand Down
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 @@ -2119,6 +2120,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
}

const end = perfNow();

this.emit('transaction', {
editor: this,
transaction: transactionToApply,
Expand Down Expand Up @@ -2494,17 +2496,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 @@ -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 @@ -463,7 +465,6 @@ export class PresentationEditor extends EventEmitter {

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

this.#hoverOverlay = doc.createElement('div');
this.#hoverOverlay.className = 'presentation-editor__hover-overlay';
Expand Down Expand Up @@ -2154,6 +2155,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 @@ -2276,7 +2283,13 @@ export class PresentationEditor extends EventEmitter {
}
};
const handleSelection = () => {
this.#scheduleSelectionUpdate();
// Use immediate rendering for selection-only changes (clicks, arrow keys).
// Without immediate, the render is RAF-deferred — leaving a window where
// a remote collaborator's edit can cancel the pending render via
// setDocEpoch → cancelScheduledRender. Immediate rendering is safe here:
// if layout is updating (due to a concurrent doc change), flushNow()
// is a no-op and the render will be picked up after layout completes.
this.#scheduleSelectionUpdate({ immediate: true });
// Update local cursor in awareness for collaboration
// This bypasses y-prosemirror's focus check which may fail for hidden PM views
this.#updateLocalAwarenessCursor();
Expand Down Expand Up @@ -2370,16 +2383,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 @@ -3170,11 +3185,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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { RemoteCursorState } from '../types.js';
*/
type AwarenessLike = {
clientID?: number;
/** Liveblocks and some providers expose clientID on the underlying Y.Doc instead */
doc?: { clientID?: number };
getStates?: () => Map<number, unknown>;
};

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

// Resolve local client ID — standard Yjs awareness exposes it as awareness.clientID,
// but some providers (e.g. Liveblocks) only expose it on the underlying Y.Doc.
const localClientId = provider.awareness?.clientID ?? provider.awareness?.doc?.clientID;

states?.forEach((aw, clientId) => {
// Skip local client
if (clientId === provider.awareness?.clientID) return;
if (localClientId != null && clientId === localClientId) return;

// Type assertion for awareness state properties
const awState = aw as {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,35 +318,6 @@ export class RemoteCursorManager {
this.#pendingUpdateCallback = callback;
}

/**
* Schedule a remote cursor re-render without re-normalizing awareness states.
* Performance optimization: avoids expensive Yjs position conversions on layout changes.
* Used when layout geometry changes but cursor positions haven't (e.g., zoom, scroll, reflow).
*/
scheduleReRender(): void {
if (this.#options.presence?.enabled === false) return;
if (this.#remoteCursorUpdateScheduled) return;
this.#remoteCursorUpdateScheduled = true;

// Use RAF for re-renders since they're triggered by layout/scroll events
const win = this.#options.visibleHost.ownerDocument?.defaultView ?? window;
win.requestAnimationFrame(() => {
this.#remoteCursorUpdateScheduled = false;
this.#lastRemoteCursorRenderTime = performance.now();
this.#pendingReRenderCallback?.();
});
}

/** Callback to invoke when scheduled re-render fires */
#pendingReRenderCallback: (() => void) | null = null;

/**
* Set the callback to invoke when a scheduled re-render fires.
*/
setReRenderCallback(callback: (() => void) | null): void {
this.#pendingReRenderCallback = callback;
}

/**
* Update remote cursor state by normalizing awareness states and rendering.
* Call this when awareness state has changed.
Expand Down Expand Up @@ -516,7 +487,6 @@ export class RemoteCursorManager {

// Clear callbacks
this.#pendingUpdateCallback = null;
this.#pendingReRenderCallback = null;
this.#onTelemetry = null;
this.#onCursorsUpdate = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2266,7 +2266,7 @@ describe('PresentationEditor', () => {

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

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

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

rafSpy.mockRestore();
});
Expand Down Expand Up @@ -2388,7 +2389,7 @@ describe('PresentationEditor', () => {
rafSpy.mockRestore();
});

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

// Schedule selection update with no guards active
// Selection update with no guards active — renders synchronously via
// immediate mode, bypassing RAF
handleSelection();

// Should schedule RAF successfully
expect(rafSpy).toHaveBeenCalledTimes(1);
// Should NOT use RAF because immediate rendering handles it synchronously
expect(rafSpy).not.toHaveBeenCalled();

rafSpy.mockRestore();
});
Expand Down
Loading
Loading