authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:45:43+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:45:43+02:00
logb3b4b57794476c2e130d38f9243f3c5da1a0cbe7
treecce08c0a6870c56657217b73eaf1cf68b84aa126
parent6ae44b418a24dc021cf042cbb1e60ebeb47e160f
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve keyboard suggestions while typing (#6323)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 242 insertions(+), 53 deletions(-)

apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+7
...@@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
252 val textLength = editor.text?.length ?: 0252 val textLength = editor.text?.length ?: 0
253 val safeStart = start.coerceIn(0, textLength)253 val safeStart = start.coerceIn(0, textLength)
254 val safeEnd = end.coerceIn(0, textLength)254 val safeEnd = end.coerceIn(0, textLength)
255 // Re-applying an unchanged selection resets the keyboard's suggestion
256 // state, so a no-op assignment must be skipped.
257 if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return
255 editor.setSelection(safeStart, safeEnd)258 editor.setSelection(safeStart, safeEnd)
256 }259 }
257260
...@@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
281 )284 )
282285
283 private fun emitSelectionChange(start: Int, end: Int) {286 private fun emitSelectionChange(start: Int, end: Int) {
287 // Caret moves advance the revision counter like text edits do: a
288 // controlled payload computed before this move is stale and must fail the
289 // revision guard instead of yanking the caret back mid-typing.
290 nativeEventCount += 1
284 onComposerSelectionChange(291 onComposerSelectionChange(
285 mapOf(292 mapOf(
286 "value" to editor.text.toString(),293 "value" to editor.text.toString(),
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+17-1
...@@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro...@@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
489 return489 return
490 }490 }
491 restoreBaseTypingAttributes()491 restoreBaseTypingAttributes()
492 // UIKit moves the selection before textViewDidChange runs. Emitting here
493 // would pair the post-edit text with a pre-edit revision counter, so let
494 // the change event that follows carry both; only pure caret moves emit.
495 guard self.textView.serializedText() == value else {
496 return
497 }
492 emitSelection()498 emitSelection()
493 }499 }
494500
...@@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro...@@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
774 }780 }
775781
776 private func emitSelection() {782 private func emitSelection() {
783 // Caret moves advance the revision counter like text edits do: a
784 // controlled payload computed before this move is stale and must fail the
785 // revision guard instead of yanking the caret back mid-typing.
777 let currentValue = textView.serializedText()786 let currentValue = textView.serializedText()
778 let selection = sourceSelection()787 let selection = sourceSelection()
788 nativeEventCount += 1
779 onComposerSelectionChange([789 onComposerSelectionChange([
780 "value": currentValue,790 "value": currentValue,
781 "selection": ["start": selection.start, "end": selection.end],791 "selection": ["start": selection.start, "end": selection.end],
...@@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro...@@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
817 NSMaxRange(nextRange) <= textView.attributedText.length else {827 NSMaxRange(nextRange) <= textView.attributedText.length else {
818 return828 return
819 }829 }
830 self.requestedSelection = nil
831 // Programmatically assigning selectedRange resets the keyboard's
832 // autocorrect and predictive-text context even when the range is
833 // unchanged, so a no-op assignment must be skipped.
834 guard !NSEqualRanges(nextRange, textView.selectedRange) else {
835 return
836 }
820 isApplyingControlledValue = true837 isApplyingControlledValue = true
821 textView.selectedRange = nextRange838 textView.selectedRange = nextRange
822 isApplyingControlledValue = false839 isApplyingControlledValue = false
823 self.requestedSelection = nil
824 }840 }
825841
826 private func updatePlaceholderVisibility() {842 private func updatePlaceholderVisibility() {
apps/mobile/src/native/T3ComposerEditor.ios.tsx+38-22
...@@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily";...@@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily";
19import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";19import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
20import {20import {
21 acknowledgeComposerNativeEvent,21 acknowledgeComposerNativeEvent,
22 assumeComposerControlledState,
22 isComposerNativeEcho,23 isComposerNativeEcho,
23 pruneAcknowledgedComposerNativeEvents,24 pruneAcknowledgedComposerNativeEvents,
24 resolveComposerControlledEventCount,25 resolveComposerControlledEventCount,
...@@ -102,11 +103,11 @@ export function ComposerEditor({...@@ -102,11 +103,11 @@ export function ComposerEditor({
102 const nativeRef = useRef<NativeComposerEditorRef>(null);103 const nativeRef = useRef<NativeComposerEditorRef>(null);
103 const mostRecentEventCountRef = useRef(0);104 const mostRecentEventCountRef = useRef(0);
104 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);105 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
105 const [nativeEventSequence, setNativeEventSequence] = useState(0);106 const [, forceNativeEventRender] = useState(0);
106 const previousRenderedEventSequenceRef = useRef(0);107 // The native editor mounts empty, so the snapshot history starts empty: the
107 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([108 // first controlled payload must be a non-echo so a restored draft (or a
108 { eventCount: 0, value: props.value, selection: selection ?? null },109 // recycled native view) is applied rather than skipped.
109 ]);110 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
110 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));111 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
111 const bodyText = useScaledTextRole("body");112 const bodyText = useScaledTextRole("body");
112 const textColor = useThemeColor("--color-foreground");113 const textColor = useThemeColor("--color-foreground");
...@@ -154,15 +155,16 @@ export function ComposerEditor({...@@ -154,15 +155,16 @@ export function ComposerEditor({
154 })),155 })),
155 );156 );
156 }, [props.value, skillLabels]);157 }, [props.value, skillLabels]);
157 const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;158 // Every render resolves against the snapshot history, so a render whose
158 const controlledEventCount = includesNativeEvent159 // (value, selection) lags the acknowledged native state is stamped behind
159 ? resolveComposerControlledEventCount(160 // the native revision and rejected by the editor instead of re-applying a
160 props.value,161 // stale caret or stale text mid-typing.
161 selection ?? null,162 const controlledEventCount = resolveComposerControlledEventCount(
162 mostRecentEventCount,163 props.value,
163 nativeEventSnapshotsRef.current,164 selection ?? null,
164 )165 mostRecentEventCount,
165 : mostRecentEventCount;166 nativeEventSnapshotsRef.current,
167 );
166 const acknowledgesLatestNativeEvent = isComposerNativeEcho(168 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
167 props.value,169 props.value,
168 selection ?? null,170 selection ?? null,
...@@ -170,9 +172,7 @@ export function ComposerEditor({...@@ -170,9 +172,7 @@ export function ComposerEditor({
170 nativeEventSnapshotsRef.current,172 nativeEventSnapshotsRef.current,
171 );173 );
172 const isNativeEcho =174 const isNativeEcho =
173 includesNativeEvent &&175 controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
174 controlledEventCount === mostRecentEventCount &&
175 acknowledgesLatestNativeEvent;
176 const controlledDocumentJson = JSON.stringify({176 const controlledDocumentJson = JSON.stringify({
177 value: props.value,177 value: props.value,
178 selection: isNativeEcho ? null : (selection ?? null),178 selection: isNativeEcho ? null : (selection ?? null),
...@@ -180,9 +180,6 @@ export function ComposerEditor({...@@ -180,9 +180,6 @@ export function ComposerEditor({
180 mostRecentEventCount: controlledEventCount,180 mostRecentEventCount: controlledEventCount,
181 isNativeEcho,181 isNativeEcho,
182 });182 });
183 useEffect(() => {
184 previousRenderedEventSequenceRef.current = nativeEventSequence;
185 }, [nativeEventSequence]);
186 useEffect(() => {183 useEffect(() => {
187 if (!acknowledgesLatestNativeEvent) return;184 if (!acknowledgesLatestNativeEvent) return;
188 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(185 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
...@@ -190,6 +187,18 @@ export function ComposerEditor({...@@ -190,6 +187,18 @@ export function ComposerEditor({
190 mostRecentEventCount,187 mostRecentEventCount,
191 );188 );
192 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);189 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);
190 const assumedValue = props.value;
191 useEffect(() => {
192 // A native event that arrived after this render was committed moves the
193 // acknowledged revision forward; the editor rejects this payload, so the
194 // snapshot history must not assume it applied.
195 if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return;
196 nativeEventSnapshotsRef.current = assumeComposerControlledState(
197 nativeEventSnapshotsRef.current,
198 controlledEventCount,
199 assumedValue,
200 );
201 }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]);
193 const acceptNativeEvent = useCallback(202 const acceptNativeEvent = useCallback(
194 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {203 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
195 const acknowledgedEventCount = acknowledgeComposerNativeEvent(204 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
...@@ -257,7 +266,7 @@ export function ComposerEditor({...@@ -257,7 +266,7 @@ export function ComposerEditor({
257 onChangeText(event.nativeEvent.value);266 onChangeText(event.nativeEvent.value);
258 onSelectionChange?.(event.nativeEvent.selection);267 onSelectionChange?.(event.nativeEvent.selection);
259 setMostRecentEventCount(acknowledgedEventCount);268 setMostRecentEventCount(acknowledgedEventCount);
260 setNativeEventSequence((sequence) => sequence + 1);269 forceNativeEventRender((sequence) => sequence + 1);
261 }}270 }}
262 onComposerSelectionChange={(event) => {271 onComposerSelectionChange={(event) => {
263 const acknowledgedEventCount = acceptNativeEvent(272 const acknowledgedEventCount = acceptNativeEvent(
...@@ -266,9 +275,16 @@ export function ComposerEditor({...@@ -266,9 +275,16 @@ export function ComposerEditor({
266 event.nativeEvent.selection,275 event.nativeEvent.selection,
267 );276 );
268 if (acknowledgedEventCount === false) return;277 if (acknowledgedEventCount === false) return;
278 // A selection change that raced a text mutation can carry post-edit
279 // text. It must reach the parent alongside the acknowledged revision,
280 // or the next render stamps the stale draft at that revision and can
281 // re-apply it over the newer native text.
282 if (event.nativeEvent.value !== props.value) {
283 onChangeText(event.nativeEvent.value);
284 }
269 onSelectionChange?.(event.nativeEvent.selection);285 onSelectionChange?.(event.nativeEvent.selection);
270 setMostRecentEventCount(acknowledgedEventCount);286 setMostRecentEventCount(acknowledgedEventCount);
271 setNativeEventSequence((sequence) => sequence + 1);287 forceNativeEventRender((sequence) => sequence + 1);
272 }}288 }}
273 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}289 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
274 onComposerFocus={onFocus}290 onComposerFocus={onFocus}
apps/mobile/src/native/T3ComposerEditor.native.tsx+39-22
...@@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";...@@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";
21import { useThemeColor } from "../lib/useThemeColor";21import { useThemeColor } from "../lib/useThemeColor";
22import {22import {
23 acknowledgeComposerNativeEvent,23 acknowledgeComposerNativeEvent,
24 assumeComposerControlledState,
24 isComposerNativeEcho,25 isComposerNativeEcho,
25 pruneAcknowledgedComposerNativeEvents,26 pruneAcknowledgedComposerNativeEvents,
26 resolveComposerControlledEventCount,27 resolveComposerControlledEventCount,
...@@ -103,11 +104,11 @@ export function ComposerEditor({...@@ -103,11 +104,11 @@ export function ComposerEditor({
103 const nativeRef = useRef<NativeComposerEditorRef>(null);104 const nativeRef = useRef<NativeComposerEditorRef>(null);
104 const mostRecentEventCountRef = useRef(0);105 const mostRecentEventCountRef = useRef(0);
105 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);106 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
106 const [nativeEventSequence, setNativeEventSequence] = useState(0);107 const [, forceNativeEventRender] = useState(0);
107 const previousRenderedEventSequenceRef = useRef(0);108 // The native editor mounts empty, so the snapshot history starts empty: the
108 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([109 // first controlled payload must be a non-echo so a restored draft (or a
109 { eventCount: 0, value: props.value, selection: selection ?? null },110 // recycled native view) is applied rather than skipped.
110 ]);111 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
111 const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));112 const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
112 const confirmedTokensRef = useRef(initialConfirmedTokens);113 const confirmedTokensRef = useRef(initialConfirmedTokens);
113 const textColor = useThemeColor("--color-foreground");114 const textColor = useThemeColor("--color-foreground");
...@@ -155,15 +156,16 @@ export function ComposerEditor({...@@ -155,15 +156,16 @@ export function ComposerEditor({
155 })),156 })),
156 );157 );
157 }, [props.value, skillLabels]);158 }, [props.value, skillLabels]);
158 const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;159 // Every render resolves against the snapshot history, so a render whose
159 const controlledEventCount = includesNativeEvent160 // (value, selection) lags the acknowledged native state is stamped behind
160 ? resolveComposerControlledEventCount(161 // the native revision and rejected by the editor instead of re-applying a
161 props.value,162 // stale caret or stale text mid-typing.
162 selection ?? null,163 const controlledEventCount = resolveComposerControlledEventCount(
163 mostRecentEventCount,164 props.value,
164 nativeEventSnapshotsRef.current,165 selection ?? null,
165 )166 mostRecentEventCount,
166 : mostRecentEventCount;167 nativeEventSnapshotsRef.current,
168 );
167 const acknowledgesLatestNativeEvent = isComposerNativeEcho(169 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
168 props.value,170 props.value,
169 selection ?? null,171 selection ?? null,
...@@ -171,9 +173,7 @@ export function ComposerEditor({...@@ -171,9 +173,7 @@ export function ComposerEditor({
171 nativeEventSnapshotsRef.current,173 nativeEventSnapshotsRef.current,
172 );174 );
173 const isNativeEcho =175 const isNativeEcho =
174 includesNativeEvent &&176 controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
175 controlledEventCount === mostRecentEventCount &&
176 acknowledgesLatestNativeEvent;
177 const controlledDocumentJson = JSON.stringify({177 const controlledDocumentJson = JSON.stringify({
178 value: props.value,178 value: props.value,
179 selection: isNativeEcho ? null : (selection ?? null),179 selection: isNativeEcho ? null : (selection ?? null),
...@@ -181,9 +181,6 @@ export function ComposerEditor({...@@ -181,9 +181,6 @@ export function ComposerEditor({
181 mostRecentEventCount: controlledEventCount,181 mostRecentEventCount: controlledEventCount,
182 isNativeEcho,182 isNativeEcho,
183 });183 });
184 useEffect(() => {
185 previousRenderedEventSequenceRef.current = nativeEventSequence;
186 }, [nativeEventSequence]);
187 useEffect(() => {184 useEffect(() => {
188 if (!acknowledgesLatestNativeEvent) return;185 if (!acknowledgesLatestNativeEvent) return;
189 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(186 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
...@@ -191,6 +188,18 @@ export function ComposerEditor({...@@ -191,6 +188,18 @@ export function ComposerEditor({
191 mostRecentEventCount,188 mostRecentEventCount,
192 );189 );
193 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);190 }, [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]);
194 const acceptNativeEvent = useCallback(203 const acceptNativeEvent = useCallback(
195 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {204 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
196 const acknowledgedEventCount = acknowledgeComposerNativeEvent(205 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
...@@ -263,7 +272,7 @@ export function ComposerEditor({...@@ -263,7 +272,7 @@ export function ComposerEditor({
263 onChangeText(event.nativeEvent.value);272 onChangeText(event.nativeEvent.value);
264 onSelectionChange?.(event.nativeEvent.selection);273 onSelectionChange?.(event.nativeEvent.selection);
265 setMostRecentEventCount(acknowledgedEventCount);274 setMostRecentEventCount(acknowledgedEventCount);
266 setNativeEventSequence((sequence) => sequence + 1);275 forceNativeEventRender((sequence) => sequence + 1);
267 }}276 }}
268 onComposerSelectionChange={(event) => {277 onComposerSelectionChange={(event) => {
269 const acknowledgedEventCount = acceptNativeEvent(278 const acknowledgedEventCount = acceptNativeEvent(
...@@ -272,9 +281,17 @@ export function ComposerEditor({...@@ -272,9 +281,17 @@ export function ComposerEditor({
272 event.nativeEvent.selection,281 event.nativeEvent.selection,
273 );282 );
274 if (acknowledgedEventCount === false) return;283 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 }
275 onSelectionChange?.(event.nativeEvent.selection);292 onSelectionChange?.(event.nativeEvent.selection);
276 setMostRecentEventCount(acknowledgedEventCount);293 setMostRecentEventCount(acknowledgedEventCount);
277 setNativeEventSequence((sequence) => sequence + 1);294 forceNativeEventRender((sequence) => sequence + 1);
278 }}295 }}
279 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}296 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
280 onComposerFocus={onFocus}297 onComposerFocus={onFocus}
apps/mobile/src/native/composerEditorRevision.test.ts+87-2
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22
3import {3import {
4 acknowledgeComposerNativeEvent,4 acknowledgeComposerNativeEvent,
5 assumeComposerControlledState,
5 isComposerNativeEcho,6 isComposerNativeEcho,
6 pruneAcknowledgedComposerNativeEvents,7 pruneAcknowledgedComposerNativeEvents,
7 resolveComposerControlledEventCount,8 resolveComposerControlledEventCount,
...@@ -42,6 +43,19 @@ describe("isComposerNativeEcho", () => {...@@ -42,6 +43,19 @@ describe("isComposerNativeEcho", () => {
42 it("matches value and revision when selection is uncontrolled", () => {43 it("matches value and revision when selection is uncontrolled", () => {
43 expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true);44 expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true);
44 });45 });
46
47 it("does not claim a controlled selection against an assumed state without one", () => {
48 // An echo payload serializes `selection: null`; classifying a controlled
49 // selection as an echo of an assumed state would drop a parent caret move.
50 const assumed = [{ eventCount: 3, value: "native", selection: null }];
51 expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false);
52 expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false);
53 });
54
55 it("matches an assumed state when selection is uncontrolled", () => {
56 const assumed = [{ eventCount: 3, value: "native", selection: null }];
57 expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true);
58 });
45});59});
4660
47describe("resolveComposerControlledEventCount", () => {61describe("resolveComposerControlledEventCount", () => {
...@@ -95,7 +109,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {...@@ -95,7 +109,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {
95 selection: { start: eventCount, end: eventCount },109 selection: { start: eventCount, end: eventCount },
96 }));110 }));
97111
98 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]);112 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]);
99 });113 });
100114
101 it("retains native events that arrive after the acknowledged render", () => {115 it("retains native events that arrive after the acknowledged render", () => {
...@@ -104,6 +118,77 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {...@@ -104,6 +118,77 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {
104 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },118 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
105 ];119 ];
106120
107 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]);121 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots);
122 });
123
124 it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => {
125 const snapshots = [
126 { eventCount: 40, value: "a", selection: { start: 1, end: 1 } },
127 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
128 { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } },
129 ];
130
131 const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42);
132 expect(pruned).toEqual([snapshots[2]]);
133 expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true);
134 });
135
136 it("keeps the newest of several snapshots sharing the acknowledged revision", () => {
137 const snapshots = [
138 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
139 { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } },
140 ];
141
142 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]);
143 });
144});
145
146describe("assumeComposerControlledState", () => {
147 it("replaces the acknowledged history with the applied controlled state", () => {
148 const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }];
149
150 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
151 { eventCount: 3, value: "", selection: null },
152 ]);
153 });
154
155 it("keeps native events that raced past the controlled revision", () => {
156 const snapshots = [
157 { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } },
158 { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } },
159 ];
160
161 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
162 { eventCount: 3, value: "", selection: null },
163 snapshots[1],
164 ]);
165 });
166
167 it("applies a parent caret move on the assumed value at the assumed revision", () => {
168 // Same value, new caret: not an echo (so the selection is serialized) but
169 // still stamped at the assumed revision so the editor accepts it.
170 const snapshots = assumeComposerControlledState([], 3, "typed");
171
172 expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false);
173 expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(
174 3,
175 );
176 });
177
178 it("re-applies a parent value that round-trips back to an acknowledged state", () => {
179 // Native acknowledged "typed", the parent then controlled the editor to ""
180 // (a send clearing the draft) and back to "typed" (the send failed and the
181 // draft was restored). The restore must be a fresh non-echo edit stamped at
182 // the current revision, not an echo the editor would drop.
183 const snapshots = assumeComposerControlledState(
184 [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }],
185 3,
186 "",
187 );
188
189 expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false);
190 expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(
191 3,
192 );
108 });193 });
109});194});
apps/mobile/src/native/composerEditorRevision.ts+54-6
...@@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount(...@@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount(
31 if (snapshot?.value !== value) continue;31 if (snapshot?.value !== value) continue;
3232
33 newestValueEventCount ??= snapshot.eventCount;33 newestValueEventCount ??= snapshot.eventCount;
34 if (34 if (selection === null || snapshotSelectionMatches(snapshot, selection)) {
35 selection === null ||
36 (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)
37 ) {
38 return snapshot.eventCount;35 return snapshot.eventCount;
39 }36 }
40 }37 }
...@@ -49,6 +46,21 @@ export function resolveComposerControlledEventCount(...@@ -49,6 +46,21 @@ export function resolveComposerControlledEventCount(
49 return mostRecentEventCount;46 return mostRecentEventCount;
50}47}
5148
49// A snapshot without a selection describes a state the editor applied itself
50// (an assumed controlled document, where the native side may have bounded the
51// caret). Revision stamping treats it as matching any controlled selection so
52// a parent caret move on the assumed value stays at the assumed revision and
53// passes the editor's staleness guard. Echo detection must not reuse this
54// wildcard: an echo payload serializes `selection: null`, which would drop
55// that caret move instead of applying it.
56function snapshotSelectionMatches(
57 snapshot: ComposerNativeEventSnapshot,
58 selection: ComposerEditorSelection,
59): boolean {
60 if (snapshot.selection === null) return true;
61 return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end;
62}
63
52export function isComposerNativeEcho(64export function isComposerNativeEcho(
53 value: string,65 value: string,
54 selection: ComposerEditorSelection | null,66 selection: ComposerEditorSelection | null,
...@@ -62,7 +74,9 @@ export function isComposerNativeEcho(...@@ -62,7 +74,9 @@ export function isComposerNativeEcho(
62 snapshot.eventCount === eventCount &&74 snapshot.eventCount === eventCount &&
63 snapshot.value === value &&75 snapshot.value === value &&
64 (selection === null ||76 (selection === null ||
65 (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end))77 (snapshot.selection !== null &&
78 snapshot.selection.start === selection.start &&
79 snapshot.selection.end === selection.end))
66 ) {80 ) {
67 return true;81 return true;
68 }82 }
...@@ -70,9 +84,43 @@ export function isComposerNativeEcho(...@@ -70,9 +84,43 @@ export function isComposerNativeEcho(
70 return false;84 return false;
71}85}
7286
87/**
88 * Records that a parent-driven controlled document was handed to the native
89 * editor. From that point the acknowledged snapshot history describes a
90 * superseded native state, so it is replaced with the assumed applied state;
91 * a later parent update back to a previously acknowledged value must classify
92 * as a fresh edit, not as a native echo the editor would drop. Native events
93 * that raced past the controlled revision stay authoritative and are kept.
94 */
95export function assumeComposerControlledState(
96 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
97 eventCount: number,
98 value: string,
99): ComposerNativeEventSnapshot[] {
100 return [
101 { eventCount, value, selection: null },
102 ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount),
103 ];
104}
105
73export function pruneAcknowledgedComposerNativeEvents(106export function pruneAcknowledgedComposerNativeEvents(
74 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,107 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
75 acknowledgedEventCount: number,108 acknowledgedEventCount: number,
76): ComposerNativeEventSnapshot[] {109): ComposerNativeEventSnapshot[] {
77 return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount);110 // The newest acknowledged snapshot must survive pruning: it is what lets a
111 // later, unrelated re-render classify the settled composer state as a native
112 // echo instead of a parent-driven edit that would re-control the caret (and
113 // reset the keyboard's autocorrect context on iOS).
114 let latestAcknowledgedIndex = -1;
115 for (let index = snapshots.length - 1; index >= 0; index -= 1) {
116 const snapshot = snapshots[index];
117 if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) {
118 latestAcknowledgedIndex = index;
119 break;
120 }
121 }
122 return snapshots.filter(
123 (snapshot, index) =>
124 index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount,
125 );
78}126}