Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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() {
Expand Down
50 changes: 28 additions & 22 deletions apps/mobile/src/native/T3ComposerEditor.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ export function ComposerEditor({
const nativeRef = useRef<NativeComposerEditorRef>(null);
const mostRecentEventCountRef = useRef(0);
const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
const [nativeEventSequence, setNativeEventSequence] = useState(0);
const previousRenderedEventSequenceRef = useRef(0);
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
{ 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<ComposerNativeEventSnapshot[]>([]);
const lastDeliveredValueRef = useRef(props.value);
const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
const bodyText = useScaledTextRole("body");
const textColor = useThemeColor("--color-foreground");
Expand Down Expand Up @@ -154,35 +155,31 @@ 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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
props.value,
selection ?? null,
mostRecentEventCount,
nativeEventSnapshotsRef.current,
);
const isNativeEcho =
includesNativeEvent &&
controlledEventCount === mostRecentEventCount &&
acknowledgesLatestNativeEvent;
controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
const controlledDocumentJson = JSON.stringify({
value: props.value,
selection: isNativeEcho ? null : (selection ?? null),
tokensJson,
mostRecentEventCount: controlledEventCount,
isNativeEcho,
});
useEffect(() => {
previousRenderedEventSequenceRef.current = nativeEventSequence;
}, [nativeEventSequence]);
useEffect(() => {
if (!acknowledgesLatestNativeEvent) return;
nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
onSelectionChange?.(event.nativeEvent.selection);
setMostRecentEventCount(acknowledgedEventCount);
setNativeEventSequence((sequence) => sequence + 1);
forceNativeEventRender((sequence) => sequence + 1);
}}
onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
onComposerFocus={onFocus}
Expand Down
50 changes: 28 additions & 22 deletions apps/mobile/src/native/T3ComposerEditor.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,12 @@ export function ComposerEditor({
const nativeRef = useRef<NativeComposerEditorRef>(null);
const mostRecentEventCountRef = useRef(0);
const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
const [nativeEventSequence, setNativeEventSequence] = useState(0);
const previousRenderedEventSequenceRef = useRef(0);
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
{ 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<ComposerNativeEventSnapshot[]>([]);
const lastDeliveredValueRef = useRef(props.value);
const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
const confirmedTokensRef = useRef(initialConfirmedTokens);
const textColor = useThemeColor("--color-foreground");
Expand Down Expand Up @@ -155,35 +156,31 @@ 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,
mostRecentEventCount,
nativeEventSnapshotsRef.current,
);
const isNativeEcho =
includesNativeEvent &&
controlledEventCount === mostRecentEventCount &&
acknowledgesLatestNativeEvent;
controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
const controlledDocumentJson = JSON.stringify({
value: props.value,
selection: isNativeEcho ? null : (selection ?? null),
tokensJson,
mostRecentEventCount: controlledEventCount,
isNativeEcho,
});
useEffect(() => {
previousRenderedEventSequenceRef.current = nativeEventSequence;
}, [nativeEventSequence]);
useEffect(() => {
if (!acknowledgesLatestNativeEvent) return;
nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
Expand Down Expand Up @@ -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(
Expand All @@ -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}
Comment thread
cursor[bot] marked this conversation as resolved.
Expand Down
25 changes: 23 additions & 2 deletions apps/mobile/src/native/composerEditorRevision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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]]);
});
});
17 changes: 16 additions & 1 deletion apps/mobile/src/native/composerEditorRevision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,20 @@ export function pruneAcknowledgedComposerNativeEvents(
snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
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,
);
}
Loading