Skip to content

Commit d7f0b3d

Browse files
ellatrixclaude
andcommitted
Keep the record selection authoritative during a stale-prop re-render
After handleChange dispatches a new selection to the store, the synchronous forceRender can re-render before the selectionStart/selectionEnd props have propagated. The render-time sync then overwrote the just-updated record with the stale props and re-applied a stale caret to the DOM, dropping characters at the wrong position while typing in a re-render-heavy context (e.g. an HTML block's static inner blocks). Track the selection just dispatched and skip the sync while the props still lag behind it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent af53431 commit d7f0b3d

1 file changed

Lines changed: 33 additions & 3 deletions

File tree

packages/rich-text/src/hook/index.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { useRef, useLayoutEffect, useReducer } from '@wordpress/element';
4+
import {
5+
useRef,
6+
useState,
7+
useLayoutEffect,
8+
useReducer,
9+
} from '@wordpress/element';
510
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
611
import { useRegistry } from '@wordpress/data';
712
import deprecated from '@wordpress/deprecated';
@@ -97,12 +102,25 @@ function useRichTextBase( {
97102

98103
const hadSelectionUpdateRef = useRef( false );
99104

105+
// The selection `handleChange` last dispatched to the store. The
106+
// `selectionStart`/`selectionEnd` props can momentarily lag it during a
107+
// synchronous re-render (e.g. while typing in a re-render-heavy context),
108+
// so the sync below must not overwrite the authoritative record with the
109+
// stale props until they have caught up. Held in state (not a ref) so it
110+
// can be read during render.
111+
const [ pendingSelection, setPendingSelection ] = useState( null );
112+
const propsLagSelection =
113+
pendingSelection !== null &&
114+
( selectionStart !== pendingSelection.start ||
115+
selectionEnd !== pendingSelection.end );
116+
100117
if ( ! recordRef.current ) {
101118
hadSelectionUpdateRef.current = isSelected;
102119
setRecordFromProps();
103120
} else if (
104-
selectionStart !== recordRef.current.start ||
105-
selectionEnd !== recordRef.current.end
121+
! propsLagSelection &&
122+
( selectionStart !== recordRef.current.start ||
123+
selectionEnd !== recordRef.current.end )
106124
) {
107125
hadSelectionUpdateRef.current = isSelected;
108126
recordRef.current = {
@@ -113,6 +131,17 @@ function useRichTextBase( {
113131
};
114132
}
115133

134+
// Clear the pending selection once the props reflect it.
135+
useLayoutEffect( () => {
136+
if (
137+
pendingSelection !== null &&
138+
selectionStart === pendingSelection.start &&
139+
selectionEnd === pendingSelection.end
140+
) {
141+
setPendingSelection( null );
142+
}
143+
}, [ selectionStart, selectionEnd, pendingSelection ] );
144+
116145
/**
117146
* Sync the value to global state. The node tree and selection will also be
118147
* updated if differences are found.
@@ -152,6 +181,7 @@ function useRichTextBase( {
152181
__unstableText: text,
153182
} );
154183
} );
184+
setPendingSelection( { start, end } );
155185
forceRender();
156186
}
157187

0 commit comments

Comments
 (0)