| author | |
| committer | |
| log | b3b4b57794476c2e130d38f9243f3c5da1a0cbe7 |
| tree | cce08c0a6870c56657217b73eaf1cf68b84aa126 |
| parent | 6ae44b418a24dc021cf042cbb1e60ebeb47e160f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 ?: 0 | 252 | 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 | } |
| 257 | 260 | ||
| ... | @@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 281 | ) | 284 | ) |
| 282 | 285 | ||
| 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 | return | 489 | 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 | } |
| 494 | 500 | ||
| ... | @@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro | ... | @@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 774 | } | 780 | } |
| 775 | 781 | ||
| 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 | return | 828 | 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 = true | 837 | isApplyingControlledValue = true |
| 821 | textView.selectedRange = nextRange | 838 | textView.selectedRange = nextRange |
| 822 | isApplyingControlledValue = false | 839 | isApplyingControlledValue = false |
| 823 | self.requestedSelection = nil | ||
| 824 | } | 840 | } |
| 825 | 841 | ||
| 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"; |
| 19 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | 19 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 20 | import { | 20 | import { |
| 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 = includesNativeEvent | 159 | // (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"; |
| 21 | import { useThemeColor } from "../lib/useThemeColor"; | 21 | import { useThemeColor } from "../lib/useThemeColor"; |
| 22 | import { | 22 | import { |
| 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 = includesNativeEvent | 160 | // (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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 46 | 60 | ||
| 47 | describe("resolveComposerControlledEventCount", () => { | 61 | describe("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 | })); |
| 97 | 111 | ||
| 98 | expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]); | 112 | expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]); |
| 99 | }); | 113 | }); |
| 100 | 114 | ||
| 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 | ]; |
| 106 | 120 | ||
| 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 | |||
| 146 | describe("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; |
| 32 | 32 | ||
| 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 | } |
| 51 | 48 | ||
| 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. | ||
| 56 | function 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 | |||
| 52 | export function isComposerNativeEcho( | 64 | export 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 | } |
| 72 | 86 | ||
| 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 | */ | ||
| 95 | export 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 | |||
| 73 | export function pruneAcknowledgedComposerNativeEvents( | 106 | export 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 | } |