@@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";
2121import { useThemeColor } from "../lib/useThemeColor" ;
2222import {
2323 acknowledgeComposerNativeEvent ,
24+ assumeComposerControlledState ,
2425 isComposerNativeEcho ,
2526 pruneAcknowledgedComposerNativeEvents ,
2627 resolveComposerControlledEventCount ,
@@ -103,11 +104,11 @@ export function ComposerEditor({
103104 const nativeRef = useRef < NativeComposerEditorRef > ( null ) ;
104105 const mostRecentEventCountRef = useRef ( 0 ) ;
105106 const [ mostRecentEventCount , setMostRecentEventCount ] = useState ( 0 ) ;
106- const [ nativeEventSequence , setNativeEventSequence ] = useState ( 0 ) ;
107- const previousRenderedEventSequenceRef = useRef ( 0 ) ;
108- const nativeEventSnapshotsRef = useRef < ComposerNativeEventSnapshot [ ] > ( [
109- { eventCount : 0 , value : props . value , selection : selection ?? null } ,
110- ] ) ;
107+ const [ , forceNativeEventRender ] = useState ( 0 ) ;
108+ // The native editor mounts empty, so the snapshot history starts empty: the
109+ // first controlled payload must be a non-echo so a restored draft (or a
110+ // recycled native view) is applied rather than skipped.
111+ const nativeEventSnapshotsRef = useRef < ComposerNativeEventSnapshot [ ] > ( [ ] ) ;
111112 const [ initialConfirmedTokens ] = useState ( ( ) => collectComposerInlineTokens ( props . value ) ) ;
112113 const confirmedTokensRef = useRef ( initialConfirmedTokens ) ;
113114 const textColor = useThemeColor ( "--color-foreground" ) ;
@@ -155,42 +156,50 @@ export function ComposerEditor({
155156 } ) ) ,
156157 ) ;
157158 } , [ props . value , skillLabels ] ) ;
158- const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef . current ;
159- const controlledEventCount = includesNativeEvent
160- ? resolveComposerControlledEventCount (
161- props . value ,
162- selection ?? null ,
163- mostRecentEventCount ,
164- nativeEventSnapshotsRef . current ,
165- )
166- : mostRecentEventCount ;
159+ // Every render resolves against the snapshot history, so a render whose
160+ // (value, selection) lags the acknowledged native state is stamped behind
161+ // the native revision and rejected by the editor instead of re-applying a
162+ // stale caret or stale text mid-typing.
163+ const controlledEventCount = resolveComposerControlledEventCount (
164+ props . value ,
165+ selection ?? null ,
166+ mostRecentEventCount ,
167+ nativeEventSnapshotsRef . current ,
168+ ) ;
167169 const acknowledgesLatestNativeEvent = isComposerNativeEcho (
168170 props . value ,
169171 selection ?? null ,
170172 mostRecentEventCount ,
171173 nativeEventSnapshotsRef . current ,
172174 ) ;
173175 const isNativeEcho =
174- includesNativeEvent &&
175- controlledEventCount === mostRecentEventCount &&
176- acknowledgesLatestNativeEvent ;
176+ controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent ;
177177 const controlledDocumentJson = JSON . stringify ( {
178178 value : props . value ,
179179 selection : isNativeEcho ? null : ( selection ?? null ) ,
180180 tokensJson,
181181 mostRecentEventCount : controlledEventCount ,
182182 isNativeEcho,
183183 } ) ;
184- useEffect ( ( ) => {
185- previousRenderedEventSequenceRef . current = nativeEventSequence ;
186- } , [ nativeEventSequence ] ) ;
187184 useEffect ( ( ) => {
188185 if ( ! acknowledgesLatestNativeEvent ) return ;
189186 nativeEventSnapshotsRef . current = pruneAcknowledgedComposerNativeEvents (
190187 nativeEventSnapshotsRef . current ,
191188 mostRecentEventCount ,
192189 ) ;
193190 } , [ acknowledgesLatestNativeEvent , mostRecentEventCount ] ) ;
191+ const assumedValue = props . value ;
192+ useEffect ( ( ) => {
193+ // A native event that arrived after this render was committed moves the
194+ // acknowledged revision forward; the editor rejects this payload, so the
195+ // snapshot history must not assume it applied.
196+ if ( isNativeEcho || controlledEventCount !== mostRecentEventCountRef . current ) return ;
197+ nativeEventSnapshotsRef . current = assumeComposerControlledState (
198+ nativeEventSnapshotsRef . current ,
199+ controlledEventCount ,
200+ assumedValue ,
201+ ) ;
202+ } , [ assumedValue , controlledEventCount , isNativeEcho , controlledDocumentJson ] ) ;
194203 const acceptNativeEvent = useCallback (
195204 ( eventCount : number , value : string , nextSelection : ComposerEditorSelection ) => {
196205 const acknowledgedEventCount = acknowledgeComposerNativeEvent (
@@ -263,7 +272,7 @@ export function ComposerEditor({
263272 onChangeText ( event . nativeEvent . value ) ;
264273 onSelectionChange ?.( event . nativeEvent . selection ) ;
265274 setMostRecentEventCount ( acknowledgedEventCount ) ;
266- setNativeEventSequence ( ( sequence ) => sequence + 1 ) ;
275+ forceNativeEventRender ( ( sequence ) => sequence + 1 ) ;
267276 } }
268277 onComposerSelectionChange = { ( event ) => {
269278 const acknowledgedEventCount = acceptNativeEvent (
@@ -272,9 +281,17 @@ export function ComposerEditor({
272281 event . nativeEvent . selection ,
273282 ) ;
274283 if ( acknowledgedEventCount === false ) return ;
284+ // Android emits the selection change mid-mutation, before the change
285+ // event, so the payload can carry post-edit text. It must reach the
286+ // parent alongside the acknowledged revision, or the next render
287+ // stamps the stale draft at that revision and can re-apply it over
288+ // the newer native text.
289+ if ( event . nativeEvent . value !== props . value ) {
290+ onChangeText ( event . nativeEvent . value ) ;
291+ }
275292 onSelectionChange ?.( event . nativeEvent . selection ) ;
276293 setMostRecentEventCount ( acknowledgedEventCount ) ;
277- setNativeEventSequence ( ( sequence ) => sequence + 1 ) ;
294+ forceNativeEventRender ( ( sequence ) => sequence + 1 ) ;
278295 } }
279296 onComposerPasteImages = { ( event ) => onPasteImages ?.( event . nativeEvent . uris ) }
280297 onComposerFocus = { onFocus }
0 commit comments