From 028c4c215382492850c958639df6c86d0d8a311b Mon Sep 17 00:00:00 2001 From: Ronald Goedeke Date: Mon, 28 Sep 2026 23:05:08 -0300 Subject: [PATCH] fix: drop a controlled selection rendered before the latest edits A selection prop rendered before JS saw the latest native edits was still applied, while the text prop in the same render was already held back by the mostRecentEventCount handshake. Typed faster than JS renders, a caret computed for '1' landed in a field showing '1234' and the next digits went in behind the first: Uno's code field took '123456' as '156234' on a Galaxy A22. The selection now uses the same guard, as React Native's TextInput does. Co-Authored-By: Claude Opus 5.5 --- packages/react-native-nitro-input/CHANGELOG.md | 10 ++++++++++ .../margelo/nitro/nitroinput/HybridNitroInputView.kt | 6 ++++++ .../ios/HybridNitroInputView.swift | 10 +++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/react-native-nitro-input/CHANGELOG.md b/packages/react-native-nitro-input/CHANGELOG.md index 3069c49..72a363d 100644 --- a/packages/react-native-nitro-input/CHANGELOG.md +++ b/packages/react-native-nitro-input/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.3.6 + +- **A controlled `selection` rendered before the latest keystrokes is + dropped**, as `value` already was and as React Native's `TextInput` does. + Typed faster than JS renders (a slow Android phone, a busy JS thread), a + caret computed for an earlier text landed in a field that already had more + in it, and the next characters went in behind the first ones: a code field + driven by `selection` took "123456" as "156234". Uncontrolled fields are + unchanged. + ## 0.3.5 - **`focusedValue`**: pass a shared value (Reanimated's `useSharedValue(false)`) diff --git a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt index 47903d6..1e88546 100644 --- a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt +++ b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt @@ -532,12 +532,18 @@ class HybridNitroInputView(private val context: ThemedReactContext) : HybridNitr } /// A controlled `selection`; -1 on either end means "leave the caret alone". + /// Like `text`, dropped when it was rendered before JS saw the latest native + /// edits (React Native's `TextInput` does the same): typed faster than JS + /// renders, a caret computed for "1" landed in a field already showing + /// "1234", and the next digits went in behind the first one. An uncontrolled + /// field sends a count of 0 and is applied as before. private fun flushSelectionIfNeeded() { if (!selectionDirty) return selectionDirty = false val start = clampInt(selectionStart, -1, Int.MAX_VALUE, -1) val end = clampInt(selectionEnd, -1, Int.MAX_VALUE, -1) if (start < 0 || end < 0) return + if (mostRecentEventCount > 0 && mostRecentEventCount < eventCount) return inputView.setSelection(start, end) } diff --git a/packages/react-native-nitro-input/ios/HybridNitroInputView.swift b/packages/react-native-nitro-input/ios/HybridNitroInputView.swift index 7886a5c..96fa869 100644 --- a/packages/react-native-nitro-input/ios/HybridNitroInputView.swift +++ b/packages/react-native-nitro-input/ios/HybridNitroInputView.swift @@ -434,13 +434,21 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { } /// A controlled `selection`: `-1` on either end means "leave the caret alone", - /// which is what an uncontrolled field sends on every render. + /// which is what an uncontrolled field sends on every render. Like `text`, + /// dropped when it was rendered before JS saw the latest native edits (React + /// Native's `TextInput` does the same): typed faster than JS renders, a caret + /// computed for "1" landed in a field already showing "1234". An + /// uncontrolled field sends a count of 0 and is applied as before. private func applySelectionIfNeeded() { guard selectionDirty else { return } selectionDirty = false let start = Self.clampInt(selectionStart, -1, Int(Int32.max), fallback: -1) let end = Self.clampInt(selectionEnd, -1, Int(Int32.max), fallback: -1) guard start >= 0, end >= 0 else { return } + snapshotLock.lock() + let nativeCount = snapshot.eventCount + snapshotLock.unlock() + if mostRecentEventCount.isFinite, mostRecentEventCount > 0, Int(mostRecentEventCount) < nativeCount { return } inputView.setSelection(start: start, end: end) }