Skip to content
Draft
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
13 changes: 11 additions & 2 deletions resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
},
Expand Down
9 changes: 8 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -63,6 +66,7 @@ object FilledTextInputRenderer {
nodeId = node.id,
setLastSent = { lastSentValue = it },
getLastSent = { lastSentValue },
echoes = echoes,
)
}

Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -74,6 +77,7 @@ object OutlinedTextInputRenderer {
nodeId = node.id,
setLastSent = { lastSentValue = it },
getLastSent = { lastSentValue },
echoes = echoes,
)
}

Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<String>()

/** 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*
Expand Down
71 changes: 67 additions & 4 deletions resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>? = nil
/// Whether a `secure` field is currently showing its contents.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
Loading