From 84465743c880508c99ab05663b62cc61016b35b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 12 Aug 2026 06:38:26 -0700 Subject: [PATCH 1/3] fix(mobile): preserve keyboard suggestions while typing - Avoid no-op selection updates that reset predictive text - Guard stale composer revisions during caret and text changes --- .../t3composereditor/T3ComposerEditorView.kt | 7 +++ .../ios/T3ComposerEditorView.swift | 18 ++++++- .../src/native/T3ComposerEditor.ios.tsx | 50 +++++++++++-------- .../src/native/T3ComposerEditor.native.tsx | 50 +++++++++++-------- .../src/native/composerEditorRevision.test.ts | 25 +++++++++- .../src/native/composerEditorRevision.ts | 17 ++++++- 6 files changed, 119 insertions(+), 48 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index e13c0a52189..3010b524099 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val textLength = editor.text?.length ?: 0 val safeStart = start.coerceIn(0, textLength) val safeEnd = end.coerceIn(0, textLength) + // Re-applying an unchanged selection resets the keyboard's suggestion + // state, so a no-op assignment must be skipped. + if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return editor.setSelection(safeStart, safeEnd) } @@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ) private fun emitSelectionChange(start: Int, end: Int) { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. + nativeEventCount += 1 onComposerSelectionChange( mapOf( "value" to editor.text.toString(), diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index ec5b54aa8f1..2a8fb8c4ea2 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } restoreBaseTypingAttributes() + // UIKit moves the selection before textViewDidChange runs. Emitting here + // would pair the post-edit text with a pre-edit revision counter, so let + // the change event that follows carry both; only pure caret moves emit. + guard self.textView.serializedText() == value else { + return + } emitSelection() } @@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitSelection() { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. let currentValue = textView.serializedText() let selection = sourceSelection() + nativeEventCount += 1 onComposerSelectionChange([ "value": currentValue, "selection": ["start": selection.start, "end": selection.end], @@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro NSMaxRange(nextRange) <= textView.attributedText.length else { return } + self.requestedSelection = nil + // Programmatically assigning selectedRange resets the keyboard's + // autocorrect and predictive-text context even when the range is + // unchanged, so a no-op assignment must be skipped. + guard !NSEqualRanges(nextRange, textView.selectedRange) else { + return + } isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false - self.requestedSelection = nil } private func updatePlaceholderVisibility() { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 4e9d62ad2c2..a049070a25a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -102,11 +102,12 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); + const lastDeliveredValueRef = useRef(props.value); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); const textColor = useThemeColor("--color-foreground"); @@ -154,15 +155,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -170,9 +172,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -180,9 +180,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -254,10 +251,11 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + lastDeliveredValueRef.current = event.nativeEvent.value; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -266,9 +264,17 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // A selection event that carries text the change handler has not + // delivered yet (the platform emitted it mid-mutation) must also + // deliver the value, or the parent's next render would round-trip + // stale text stamped with this acknowledged revision. + if (event.nativeEvent.value !== lastDeliveredValueRef.current) { + lastDeliveredValueRef.current = event.nativeEvent.value; + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index e78f90a7db9..14a89db9d00 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -103,11 +103,12 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); + const lastDeliveredValueRef = useRef(props.value); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); const textColor = useThemeColor("--color-foreground"); @@ -155,15 +156,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -171,9 +173,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -181,9 +181,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -260,10 +257,11 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + lastDeliveredValueRef.current = event.nativeEvent.value; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -272,9 +270,17 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // A selection event that carries text the change handler has not + // delivered yet (the platform emitted it mid-mutation) must also + // deliver the value, or the parent's next render would round-trip + // stale text stamped with this acknowledged revision. + if (event.nativeEvent.value !== lastDeliveredValueRef.current) { + lastDeliveredValueRef.current = event.nativeEvent.value; + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index 9b255a5477a..e5755f1861d 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -95,7 +95,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { selection: { start: eventCount, end: eventCount }, })); - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]); }); it("retains native events that arrive after the acknowledged render", () => { @@ -104,6 +104,27 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, ]; - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots); + }); + + it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => { + const snapshots = [ + { eventCount: 40, value: "a", selection: { start: 1, end: 1 } }, + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } }, + ]; + + const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42); + expect(pruned).toEqual([snapshots[2]]); + expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true); + }); + + it("keeps the newest of several snapshots sharing the acknowledged revision", () => { + const snapshots = [ + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } }, + ]; + + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]); }); }); diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index ea18d153d53..1b365ea3735 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -74,5 +74,20 @@ export function pruneAcknowledgedComposerNativeEvents( snapshots: ReadonlyArray, acknowledgedEventCount: number, ): ComposerNativeEventSnapshot[] { - return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount); + // The newest acknowledged snapshot must survive pruning: it is what lets a + // later, unrelated re-render classify the settled composer state as a native + // echo instead of a parent-driven edit that would re-control the caret (and + // reset the keyboard's autocorrect context on iOS). + let latestAcknowledgedIndex = -1; + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const snapshot = snapshots[index]; + if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) { + latestAcknowledgedIndex = index; + break; + } + } + return snapshots.filter( + (snapshot, index) => + index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount, + ); } From 8b7bbd1f987ea31b2fb0d32ea54c0f2f9ded4006 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 00:12:24 -0700 Subject: [PATCH 2/3] fix(mobile): address composer review findings Track assumed native state when a parent-driven controlled document is sent, so a later parent update back to a previously acknowledged value (e.g. a draft restored after a failed send) applies as a fresh edit instead of being dropped as a native echo. Remove the selection-handler value delivery that could publish the empty editor's text over a restored draft; stale round-trips are already rejected by revision stamping. Co-Authored-By: Claude Fable 5 --- .../src/native/T3ComposerEditor.ios.tsx | 23 ++++++---- .../src/native/T3ComposerEditor.native.tsx | 23 ++++++---- .../src/native/composerEditorRevision.test.ts | 46 +++++++++++++++++++ .../src/native/composerEditorRevision.ts | 39 +++++++++++++--- 4 files changed, 105 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index a049070a25a..dd8f1a4997c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -107,7 +108,6 @@ export function ComposerEditor({ // first controlled payload must be a non-echo so a restored draft (or a // recycled native view) is applied rather than skipped. const nativeEventSnapshotsRef = useRef([]); - const lastDeliveredValueRef = useRef(props.value); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); const textColor = useThemeColor("--color-foreground"); @@ -187,6 +187,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -251,7 +263,6 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; - lastDeliveredValueRef.current = event.nativeEvent.value; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); @@ -264,14 +275,6 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; - // A selection event that carries text the change handler has not - // delivered yet (the platform emitted it mid-mutation) must also - // deliver the value, or the parent's next render would round-trip - // stale text stamped with this acknowledged revision. - if (event.nativeEvent.value !== lastDeliveredValueRef.current) { - lastDeliveredValueRef.current = event.nativeEvent.value; - onChangeText(event.nativeEvent.value); - } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); forceNativeEventRender((sequence) => sequence + 1); diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 14a89db9d00..3d29917314c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -108,7 +109,6 @@ export function ComposerEditor({ // first controlled payload must be a non-echo so a restored draft (or a // recycled native view) is applied rather than skipped. const nativeEventSnapshotsRef = useRef([]); - const lastDeliveredValueRef = useRef(props.value); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); const textColor = useThemeColor("--color-foreground"); @@ -188,6 +188,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -257,7 +269,6 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; - lastDeliveredValueRef.current = event.nativeEvent.value; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); @@ -270,14 +281,6 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; - // A selection event that carries text the change handler has not - // delivered yet (the platform emitted it mid-mutation) must also - // deliver the value, or the parent's next render would round-trip - // stale text stamped with this acknowledged revision. - if (event.nativeEvent.value !== lastDeliveredValueRef.current) { - lastDeliveredValueRef.current = event.nativeEvent.value; - onChangeText(event.nativeEvent.value); - } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); forceNativeEventRender((sequence) => sequence + 1); diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index e5755f1861d..f83766efc40 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -42,6 +43,12 @@ describe("isComposerNativeEcho", () => { it("matches value and revision when selection is uncontrolled", () => { expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); }); + + it("matches any controlled selection against an assumed state without one", () => { + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(true); + expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false); + }); }); describe("resolveComposerControlledEventCount", () => { @@ -128,3 +135,42 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]); }); }); + +describe("assumeComposerControlledState", () => { + it("replaces the acknowledged history with the applied controlled state", () => { + const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + ]); + }); + + it("keeps native events that raced past the controlled revision", () => { + const snapshots = [ + { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } }, + ]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + snapshots[1], + ]); + }); + + it("re-applies a parent value that round-trips back to an acknowledged state", () => { + // Native acknowledged "typed", the parent then controlled the editor to "" + // (a send clearing the draft) and back to "typed" (the send failed and the + // draft was restored). The restore must be a fresh non-echo edit stamped at + // the current revision, not an echo the editor would drop. + const snapshots = assumeComposerControlledState( + [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }], + 3, + "", + ); + + expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe( + 3, + ); + }); +}); diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index 1b365ea3735..531e76c45c0 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount( if (snapshot?.value !== value) continue; newestValueEventCount ??= snapshot.eventCount; - if ( - selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end) - ) { + if (selection === null || snapshotSelectionMatches(snapshot, selection)) { return snapshot.eventCount; } } @@ -49,6 +46,18 @@ export function resolveComposerControlledEventCount( return mostRecentEventCount; } +// A snapshot without a selection describes a state the editor applied itself +// (an assumed controlled document, where the native side may have bounded the +// caret). It matches any controlled selection: such matches only ever produce +// echoes, and echo payloads never control the caret. +function snapshotSelectionMatches( + snapshot: ComposerNativeEventSnapshot, + selection: ComposerEditorSelection, +): boolean { + if (snapshot.selection === null) return true; + return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end; +} + export function isComposerNativeEcho( value: string, selection: ComposerEditorSelection | null, @@ -61,8 +70,7 @@ export function isComposerNativeEcho( snapshot !== undefined && snapshot.eventCount === eventCount && snapshot.value === value && - (selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)) + (selection === null || snapshotSelectionMatches(snapshot, selection)) ) { return true; } @@ -70,6 +78,25 @@ export function isComposerNativeEcho( return false; } +/** + * Records that a parent-driven controlled document was handed to the native + * editor. From that point the acknowledged snapshot history describes a + * superseded native state, so it is replaced with the assumed applied state; + * a later parent update back to a previously acknowledged value must classify + * as a fresh edit, not as a native echo the editor would drop. Native events + * that raced past the controlled revision stay authoritative and are kept. + */ +export function assumeComposerControlledState( + snapshots: ReadonlyArray, + eventCount: number, + value: string, +): ComposerNativeEventSnapshot[] { + return [ + { eventCount, value, selection: null }, + ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount), + ]; +} + export function pruneAcknowledgedComposerNativeEvents( snapshots: ReadonlyArray, acknowledgedEventCount: number, From 2e0b505e90917d6c573faec1f81cbc3804b5fcd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 14 Aug 2026 01:07:57 -0700 Subject: [PATCH 3/3] fix(mobile): apply parent caret moves and mid-mutation text on assumed composer states An assumed controlled snapshot (selection: null) matched any controlled selection in isComposerNativeEcho, so a parent-only caret move on the same value was classified as an echo, serialized with selection: null, and dropped. Echo detection now requires an exact selection match; the wildcard stays in resolveComposerControlledEventCount so the caret move is still stamped at the assumed revision and accepted by the editor. Selection events can also race a text mutation on Android (the caret moves before afterTextChanged), so their payload can carry post-edit text at a newly acknowledged revision. Forward that value through onChangeText so the parent draft cannot be stamped stale at the acknowledged revision and re-applied over newer native text. Co-Authored-By: Claude Fable 5 --- .../src/native/T3ComposerEditor.ios.tsx | 7 ++++++ .../src/native/T3ComposerEditor.native.tsx | 8 +++++++ .../src/native/composerEditorRevision.test.ts | 22 +++++++++++++++++-- .../src/native/composerEditorRevision.ts | 12 +++++++--- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index dd8f1a4997c..32094109b1f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -275,6 +275,13 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // A selection change that raced a text mutation can carry post-edit + // text. It must reach the parent alongside the acknowledged revision, + // or the next render stamps the stale draft at that revision and can + // re-apply it over the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); forceNativeEventRender((sequence) => sequence + 1); diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 3d29917314c..ff177abf164 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -281,6 +281,14 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // Android emits the selection change mid-mutation, before the change + // event, so the payload can carry post-edit text. It must reach the + // parent alongside the acknowledged revision, or the next render + // stamps the stale draft at that revision and can re-apply it over + // the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); forceNativeEventRender((sequence) => sequence + 1); diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index f83766efc40..ccc2214e24c 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -44,11 +44,18 @@ describe("isComposerNativeEcho", () => { expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); }); - it("matches any controlled selection against an assumed state without one", () => { + it("does not claim a controlled selection against an assumed state without one", () => { + // An echo payload serializes `selection: null`; classifying a controlled + // selection as an echo of an assumed state would drop a parent caret move. const assumed = [{ eventCount: 3, value: "native", selection: null }]; - expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(true); + expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false); expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false); }); + + it("matches an assumed state when selection is uncontrolled", () => { + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true); + }); }); describe("resolveComposerControlledEventCount", () => { @@ -157,6 +164,17 @@ describe("assumeComposerControlledState", () => { ]); }); + it("applies a parent caret move on the assumed value at the assumed revision", () => { + // Same value, new caret: not an echo (so the selection is serialized) but + // still stamped at the assumed revision so the editor accepts it. + const snapshots = assumeComposerControlledState([], 3, "typed"); + + expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe( + 3, + ); + }); + it("re-applies a parent value that round-trips back to an acknowledged state", () => { // Native acknowledged "typed", the parent then controlled the editor to "" // (a send clearing the draft) and back to "typed" (the send failed and the diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index 531e76c45c0..45d68ac1b65 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -48,8 +48,11 @@ export function resolveComposerControlledEventCount( // A snapshot without a selection describes a state the editor applied itself // (an assumed controlled document, where the native side may have bounded the -// caret). It matches any controlled selection: such matches only ever produce -// echoes, and echo payloads never control the caret. +// caret). Revision stamping treats it as matching any controlled selection so +// a parent caret move on the assumed value stays at the assumed revision and +// passes the editor's staleness guard. Echo detection must not reuse this +// wildcard: an echo payload serializes `selection: null`, which would drop +// that caret move instead of applying it. function snapshotSelectionMatches( snapshot: ComposerNativeEventSnapshot, selection: ComposerEditorSelection, @@ -70,7 +73,10 @@ export function isComposerNativeEcho( snapshot !== undefined && snapshot.eventCount === eventCount && snapshot.value === value && - (selection === null || snapshotSelectionMatches(snapshot, selection)) + (selection === null || + (snapshot.selection !== null && + snapshot.selection.start === selection.start && + snapshot.selection.end === selection.end)) ) { return true; }