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