Skip to content

Commit b3b4b57

Browse files
fix(mobile): preserve keyboard suggestions while typing (#6323)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ae44b4 commit b3b4b57

6 files changed

Lines changed: 242 additions & 53 deletions

File tree

apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
252252
val textLength = editor.text?.length ?: 0
253253
val safeStart = start.coerceIn(0, textLength)
254254
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
255258
editor.setSelection(safeStart, safeEnd)
256259
}
257260

@@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
281284
)
282285

283286
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
284291
onComposerSelectionChange(
285292
mapOf(
286293
"value" to editor.text.toString(),

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
489489
return
490490
}
491491
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+
}
492498
emitSelection()
493499
}
494500

@@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
774780
}
775781

776782
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.
777786
let currentValue = textView.serializedText()
778787
let selection = sourceSelection()
788+
nativeEventCount += 1
779789
onComposerSelectionChange([
780790
"value": currentValue,
781791
"selection": ["start": selection.start, "end": selection.end],
@@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
817827
NSMaxRange(nextRange) <= textView.attributedText.length else {
818828
return
819829
}
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+
}
820837
isApplyingControlledValue = true
821838
textView.selectedRange = nextRange
822839
isApplyingControlledValue = false
823-
self.requestedSelection = nil
824840
}
825841

826842
private func updatePlaceholderVisibility() {

apps/mobile/src/native/T3ComposerEditor.ios.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily";
1919
import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
2020
import {
2121
acknowledgeComposerNativeEvent,
22+
assumeComposerControlledState,
2223
isComposerNativeEcho,
2324
pruneAcknowledgedComposerNativeEvents,
2425
resolveComposerControlledEventCount,
@@ -102,11 +103,11 @@ export function ComposerEditor({
102103
const nativeRef = useRef<NativeComposerEditorRef>(null);
103104
const mostRecentEventCountRef = useRef(0);
104105
const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
105-
const [nativeEventSequence, setNativeEventSequence] = useState(0);
106-
const previousRenderedEventSequenceRef = useRef(0);
107-
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
108-
{ eventCount: 0, value: props.value, selection: selection ?? null },
109-
]);
106+
const [, forceNativeEventRender] = useState(0);
107+
// The native editor mounts empty, so the snapshot history starts empty: the
108+
// first controlled payload must be a non-echo so a restored draft (or a
109+
// recycled native view) is applied rather than skipped.
110+
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
110111
const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
111112
const bodyText = useScaledTextRole("body");
112113
const textColor = useThemeColor("--color-foreground");
@@ -154,42 +155,50 @@ export function ComposerEditor({
154155
})),
155156
);
156157
}, [props.value, skillLabels]);
157-
const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;
158-
const controlledEventCount = includesNativeEvent
159-
? resolveComposerControlledEventCount(
160-
props.value,
161-
selection ?? null,
162-
mostRecentEventCount,
163-
nativeEventSnapshotsRef.current,
164-
)
165-
: mostRecentEventCount;
158+
// Every render resolves against the snapshot history, so a render whose
159+
// (value, selection) lags the acknowledged native state is stamped behind
160+
// the native revision and rejected by the editor instead of re-applying a
161+
// stale caret or stale text mid-typing.
162+
const controlledEventCount = resolveComposerControlledEventCount(
163+
props.value,
164+
selection ?? null,
165+
mostRecentEventCount,
166+
nativeEventSnapshotsRef.current,
167+
);
166168
const acknowledgesLatestNativeEvent = isComposerNativeEcho(
167169
props.value,
168170
selection ?? null,
169171
mostRecentEventCount,
170172
nativeEventSnapshotsRef.current,
171173
);
172174
const isNativeEcho =
173-
includesNativeEvent &&
174-
controlledEventCount === mostRecentEventCount &&
175-
acknowledgesLatestNativeEvent;
175+
controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
176176
const controlledDocumentJson = JSON.stringify({
177177
value: props.value,
178178
selection: isNativeEcho ? null : (selection ?? null),
179179
tokensJson,
180180
mostRecentEventCount: controlledEventCount,
181181
isNativeEcho,
182182
});
183-
useEffect(() => {
184-
previousRenderedEventSequenceRef.current = nativeEventSequence;
185-
}, [nativeEventSequence]);
186183
useEffect(() => {
187184
if (!acknowledgesLatestNativeEvent) return;
188185
nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
189186
nativeEventSnapshotsRef.current,
190187
mostRecentEventCount,
191188
);
192189
}, [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]);
193202
const acceptNativeEvent = useCallback(
194203
(eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
195204
const acknowledgedEventCount = acknowledgeComposerNativeEvent(
@@ -257,7 +266,7 @@ export function ComposerEditor({
257266
onChangeText(event.nativeEvent.value);
258267
onSelectionChange?.(event.nativeEvent.selection);
259268
setMostRecentEventCount(acknowledgedEventCount);
260-
setNativeEventSequence((sequence) => sequence + 1);
269+
forceNativeEventRender((sequence) => sequence + 1);
261270
}}
262271
onComposerSelectionChange={(event) => {
263272
const acknowledgedEventCount = acceptNativeEvent(
@@ -266,9 +275,16 @@ export function ComposerEditor({
266275
event.nativeEvent.selection,
267276
);
268277
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+
}
269285
onSelectionChange?.(event.nativeEvent.selection);
270286
setMostRecentEventCount(acknowledgedEventCount);
271-
setNativeEventSequence((sequence) => sequence + 1);
287+
forceNativeEventRender((sequence) => sequence + 1);
272288
}}
273289
onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
274290
onComposerFocus={onFocus}

apps/mobile/src/native/T3ComposerEditor.native.tsx

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";
2121
import { useThemeColor } from "../lib/useThemeColor";
2222
import {
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

Comments
 (0)