Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
60 changes: 38 additions & 22 deletions apps/mobile/src/native/T3ComposerEditor.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily";
import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
import {
acknowledgeComposerNativeEvent,
assumeComposerControlledState,
isComposerNativeEcho,
pruneAcknowledgedComposerNativeEvents,
resolveComposerControlledEventCount,
Expand Down Expand Up @@ -102,11 +103,11 @@ 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 confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
const bodyText = useScaledTextRole("body");
const textColor = useThemeColor("--color-foreground");
Expand Down Expand Up @@ -154,42 +155,50 @@ 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(
nativeEventSnapshotsRef.current,
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(
Expand Down Expand Up @@ -257,7 +266,7 @@ export function ComposerEditor({
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 +275,16 @@ 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);
setNativeEventSequence((sequence) => sequence + 1);
forceNativeEventRender((sequence) => sequence + 1);
}}
onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
onComposerFocus={onFocus}
Expand Down
61 changes: 39 additions & 22 deletions apps/mobile/src/native/T3ComposerEditor.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";
import { useThemeColor } from "../lib/useThemeColor";
import {
acknowledgeComposerNativeEvent,
assumeComposerControlledState,
isComposerNativeEcho,
pruneAcknowledgedComposerNativeEvents,
resolveComposerControlledEventCount,
Expand Down Expand Up @@ -103,11 +104,11 @@ 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 [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
const confirmedTokensRef = useRef(initialConfirmedTokens);
const textColor = useThemeColor("--color-foreground");
Expand Down Expand Up @@ -155,42 +156,50 @@ 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(
nativeEventSnapshotsRef.current,
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(
Expand Down Expand Up @@ -263,7 +272,7 @@ export function ComposerEditor({
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 +281,17 @@ 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);
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
Loading
Loading