@@ -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 ) {
0 commit comments