From 4a4731ddf6877fe5fcd6d62ab775f9f94d1bc883 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 27 Sep 2026 18:58:32 +0100 Subject: [PATCH] Stop stale model echoes from overwriting newer text in text inputs With native:model every keystroke sends a change event and PHP re-renders once per event. When typing outpaces the round trip, several renders are in flight. The field only remembered the newest value it sent, so the echo of an older keystroke looked like a real server change and replaced whatever had been typed since. On iOS that replaced text was then sent back to PHP, so old letters kept bouncing around. Each field now keeps a short queue of values it has sent and not yet seen come back. PHP handles events in order, so an incoming value that matches the queue is an echo: drop it and everything older. A value that isn't in the queue is something PHP set on purpose, like clearing the field after Add, and is applied as before. Covers the shared iOS core and all three Android variants. Co-Authored-By: Claude Opus 5.5 (1M context) --- resources/android/BareTextInputRenderer.kt | 13 +++- resources/android/FilledTextInputRenderer.kt | 9 ++- .../android/OutlinedTextInputRenderer.kt | 9 ++- resources/android/TextInputShared.kt | 57 +++++++++++++++ resources/ios/NativeUITextInputCore.swift | 71 +++++++++++++++++-- 5 files changed, 151 insertions(+), 8 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index dba10c8..deeff4c 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -79,6 +79,9 @@ object BareTextInputRenderer { val scope = rememberCoroutineScope() var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } + // Values sent to PHP whose re-render hasn't come back yet; see + // TextEchoTracker for why `lastSentValue` alone isn't enough. + val echoes = remember { TextEchoTracker(props.serverValue) } var wasFocused by remember { mutableStateOf(false) } // Caret / selection reporter — independent of the direct change/submit @@ -89,7 +92,10 @@ object BareTextInputRenderer { } LaunchedEffect(props.serverValue) { - if (props.serverValue != lastSentValue) { + // An echo of an earlier keystroke must not overwrite what the user + // has typed since, so check the whole in-flight queue, not just + // the newest value sent. + if (echoes.receive(props.serverValue) && props.serverValue != lastSentValue) { // Programmatic server push: replace text, caret to the end, and // flush a single end-caret selection event (deduped) rather // than emitting stale pre-push offsets. @@ -115,7 +121,10 @@ object BareTextInputRenderer { // the change event (parity with the String overload). if (textChanged) { lastSentValue = newValue.text - props.dispatchChange?.invoke(newValue.text) + props.dispatchChange?.let { dispatch -> + echoes.sent(newValue.text) + dispatch(newValue.text) + } } selectionReporter.onValueChanged(newValue) }, diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 270bb67..dd52288 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -49,6 +49,9 @@ object FilledTextInputRenderer { // the end of any pre-filled value, matching the server-push below. var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } + // Values sent to PHP whose re-render hasn't come back yet; see + // TextEchoTracker for why `lastSentValue` alone isn't enough. + val echoes = remember { TextEchoTracker(props.serverValue) } // Reveal state for a `revealable` secure field. Local on purpose, and // that is the whole safety argument for the feature: flipping it never @@ -63,6 +66,7 @@ object FilledTextInputRenderer { nodeId = node.id, setLastSent = { lastSentValue = it }, getLastSent = { lastSentValue }, + echoes = echoes, ) } @@ -73,7 +77,10 @@ object FilledTextInputRenderer { } LaunchedEffect(props.serverValue) { - if (props.serverValue != lastSentValue) { + // An echo of an earlier keystroke must not overwrite what the user + // has typed since, so check the whole in-flight queue, not just + // the newest value sent. + if (echoes.receive(props.serverValue) && props.serverValue != lastSentValue) { // Programmatic server push: replace text, caret to the end, and // flush a single end-caret selection event (deduped) rather // than emitting stale pre-push offsets. diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index a7d78e0..ebe1059 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -58,6 +58,9 @@ object OutlinedTextInputRenderer { // the server-push behavior below). var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } + // Values sent to PHP whose re-render hasn't come back yet; see + // TextEchoTracker for why `lastSentValue` alone isn't enough. + val echoes = remember { TextEchoTracker(props.serverValue) } // Reveal state for a `revealable` secure field. Local on purpose, and // that is the whole safety argument for the feature: flipping it never @@ -74,6 +77,7 @@ object OutlinedTextInputRenderer { nodeId = node.id, setLastSent = { lastSentValue = it }, getLastSent = { lastSentValue }, + echoes = echoes, ) } @@ -84,7 +88,10 @@ object OutlinedTextInputRenderer { } LaunchedEffect(props.serverValue) { - if (props.serverValue != lastSentValue) { + // An echo of an earlier keystroke must not overwrite what the user + // has typed since, so check the whole in-flight queue, not just + // the newest value sent. + if (echoes.receive(props.serverValue) && props.serverValue != lastSentValue) { // Programmatic server push: replace the text and drop the caret // at the very end (parity with the pre-migration String sync, // which reset the field wholesale). We do NOT emit stale diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 26f6bb7..2b779e7 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -257,6 +257,7 @@ internal class TextInputDispatcher( private val nodeId: Int, private val setLastSent: (String) -> Unit, private val getLastSent: () -> String, + private val echoes: TextEchoTracker, ) { private var debounceJob: Job? = null @@ -305,11 +306,67 @@ internal class TextInputDispatcher( private fun commit(value: String) { setLastSent(value) if (props.onChangeCb != 0) { + echoes.sent(value) NativeUIBridge.sendTextChangeEvent(props.onChangeCb, nodeId, value) } } } +/** + * Tells an echo of our own change apart from a value PHP actually set. + * + * Every change event makes PHP re-render, and the tree it publishes carries + * the value from THAT event. When keystrokes arrive faster than the round + * trip, several of those renders are in flight at once. The field used to + * remember only the newest value it sent, so the echo of an older keystroke + * looked like a real server change and replaced everything typed since + * ("Buy milk" saved as "Buyilk"). + * + * PHP handles events one at a time and in order, so echoes come back in the + * order they were sent, though recomposition may skip some when trees arrive + * faster than it draws. Matching an incoming value against the queue and + * dropping everything up to it handles both. A value that isn't in the queue + * is something PHP chose (clearing the field after "Add", say) and is applied + * as before. + * + * Main-thread only, like the rest of the field state. + */ +internal class TextEchoTracker(private var serverValue: String) { + /** Unacknowledged values, oldest first. */ + private val inFlight = ArrayDeque() + + /** Record a value just sent to PHP. */ + fun sent(value: String) { + // With nothing in flight PHP already holds this value, so its echo + // won't change the published tree and would never be seen. Leaving it + // out of the queue stops it from later masking a real push of the + // same value. + if (inFlight.isEmpty() && value == serverValue) return + inFlight.addLast(value) + while (inFlight.size > LIMIT) inFlight.removeFirst() + } + + /** + * Record a newly published server value. Returns true when it should be + * applied to the field, false when it is an echo of our own change. + */ + fun receive(value: String): Boolean { + serverValue = value + val index = inFlight.indexOf(value) + if (index >= 0) { + repeat(index + 1) { inFlight.removeFirst() } + return false + } + inFlight.clear() + return true + } + + private companion object { + /** Bounds the queue for a field whose model PHP never publishes back. */ + const val LIMIT = 64 + } +} + /** * Caret / selection reporter. Independent of the [TextInputDispatcher] model * (sync_mode / debounce_ms) policy — it fires whenever the *text or selection* diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index bedcece..aaa8653 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -41,6 +41,9 @@ struct NativeUITextInputCore: View { @State private var text: String = "" @State private var lastSentValue: String = "" + /// Values sent to PHP whose re-render hasn't come back yet. See + /// `NativeUITextEchoTracker` for why `lastSentValue` alone isn't enough. + @State private var echoes = NativeUITextEchoTracker() @State private var initialized: Bool = false @State private var debounceTask: Task? = nil /// Whether a `secure` field is currently showing its contents. @@ -222,6 +225,7 @@ struct NativeUITextInputCore: View { if !initialized { text = serverValue lastSentValue = serverValue + echoes.reset(serverValue: serverValue) initialized = true // First appearance only: a later re-render must not steal @@ -234,10 +238,13 @@ struct NativeUITextInputCore: View { } } .onChange(of: serverValue) { _, newServerValue in - // Only sync from server when the incoming value differs from what - // we last sent. Matching == it's an echo of our own change; ignore - // to avoid cursor jumps / clobbering in-flight edits. - if newServerValue != lastSentValue { + // Only sync from server when the incoming value is not an echo of + // something we sent. PHP re-renders once per change event, so with + // fast typing several echoes are in flight and an EARLIER one can + // land after the user has typed more. Comparing only against + // `lastSentValue` would let that stale echo overwrite the newer + // text, which is how letters went missing. + if echoes.receive(newServerValue) && newServerValue != lastSentValue { text = newServerValue lastSentValue = newServerValue // A programmatic push replaces the field wholesale and drops @@ -410,6 +417,7 @@ struct NativeUITextInputCore: View { private func commit(_ value: String, onChangeCb: Int) { lastSentValue = value if onChangeCb != 0 { + echoes.sent(value) NativeElementBridge.sendTextChangeEvent(onChangeCb, nodeId: node.id, text: value) } } @@ -535,6 +543,61 @@ struct NativeUITextInputCore: View { } } +/// Tells an echo of our own change apart from a value PHP actually set. +/// +/// Every change event makes PHP re-render, and the tree it publishes carries +/// the value from THAT event. When keystrokes arrive faster than the round +/// trip, several of those renders are in flight at once. The field used to +/// remember only the newest value it sent, so the echo of an older keystroke +/// looked like a real server change and replaced everything typed since +/// ("Buy milk" saved as "Buyilk"). +/// +/// PHP handles events one at a time and in order, so echoes come back in the +/// order they were sent, though the renderer may skip some when trees arrive +/// faster than it draws. Matching an incoming value against the queue and +/// dropping everything up to it handles both. A value that isn't in the queue +/// is something PHP chose (clearing the field after "Add", say) and is applied +/// as before. +struct NativeUITextEchoTracker { + /// Unacknowledged values, oldest first. + private(set) var inFlight: [String] = [] + /// The newest value PHP has published for this field. + private var serverValue: String = "" + + /// Bounds the queue for a field whose model PHP never publishes back. + static let limit = 64 + + mutating func reset(serverValue: String) { + inFlight.removeAll() + self.serverValue = serverValue + } + + /// Record a value just sent to PHP. + mutating func sent(_ value: String) { + // With nothing in flight PHP already holds this value, so its echo + // won't change the published tree and would never be seen. Leaving it + // out of the queue stops it from later masking a real push of the + // same value. + if inFlight.isEmpty && value == serverValue { return } + inFlight.append(value) + if inFlight.count > Self.limit { + inFlight.removeFirst(inFlight.count - Self.limit) + } + } + + /// Record a newly published server value. Returns true when it should be + /// applied to the field, false when it is an echo of our own change. + mutating func receive(_ value: String) -> Bool { + serverValue = value + if let index = inFlight.firstIndex(of: value) { + inFlight.removeFirst(index + 1) + return false + } + inFlight.removeAll() + return true + } +} + /// Snapshot of a reported selection — the dedupe key and the debounce payload. private struct NativeUISelectionPayload: Equatable { let text: String