Engineering

The text field that dropped characters on one target only

IME composition, autocorrect and hardware keyboards reach Compose differently per platform, so state hoisted too far loses keystrokes on exactly one of them.

MobileByteSensei 11 min read
kmpcomposeinput

TL;DR

  • A search field dropped characters — on Android, with Gboard, in Japanese and when typing fast in English. 18% of Android search sessions contained a mangled query. Zero on iOS, desktop and web.
  • The cause was not Compose and not the keyboard. It was hoisting TextFieldValue through a ViewModel with a StateFlow: the IME's composing region is state the keyboard owns mid-word, and a round trip through another thread invalidates it.
  • The fix keeps the field's own state local and sends committed text outward — which is a different shape from "hoist all state", and the reason the usual advice fails here.
  • What reading on buys: the composing-region mechanism, why only one platform shows it, and the keyboard-by-keyboard measurement.

The case this post is about

A search field, hoisted the way state hoisting is normally taught:

// feature/search/src/commonMain/kotlin/SearchViewModel.kt
class SearchViewModel(private val repo: SearchRepo, d: AppDispatchers) : BaseViewModel(d) {
    private val _query = MutableStateFlow(TextFieldValue(""))
    val query: StateFlow<TextFieldValue> = _query.asStateFlow()

    fun onQueryChange(v: TextFieldValue) { _query.value = v }

    val results = _query
        .map { it.text }
        .debounce(300)
        .distinctUntilChanged()
        .mapLatest { repo.search(it) }
        .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
}
// feature/search — the screen
@Composable
fun SearchScreen(vm: SearchViewModel) {
    val query by vm.query.collectAsStateWithLifecycle()
    TextField(value = query, onValueChange = vm::onQueryChange)   // ← the round trip
    …
}

Single source of truth, state hoisted to the ViewModel, debounced search. This is the shape every tutorial shows, and it is wrong for a text field specifically.

The symptom

Search queries arriving mangled, and only from Android:

# search sessions containing a query the user visibly corrected or abandoned, 1 week
  platform    sessions    mangled    rate
  android       14,200      2,556    18.0%
  ios            9,840         11     0.1%
  desktop        1,120          2     0.2%
  web            4,610          8     0.2%

The mangling had a shape, which is what eventually identified it:

  intended      arrived        keyboard / language
  "flights"     "fligh"        Gboard, English, typing fast
  "flights"     "flightss"     Gboard, English
  "東京"         "とうky"        Gboard, Japanese
  "münchen"     "mnchen"       Gboard, German (umlaut via long-press)
  "flights"     "flights"      AOSP keyboard, English — correct

Fast typing lost a trailing character or doubled one. Japanese lost the conversion entirely. And the stock AOSP keyboard was fine, which sent the first day's investigation into "it's a Gboard bug".

# where the week went
  "it's a Gboard bug"                        2 days  (filed, then withdrawn)
  "it's the debounce"                         1 day   (removed it; no change)
  "it's recomposition"                        2 days
  the composing region                        2 hours

One week of search sessions showing eighteen percent mangled on one platform against a tenth of a percent on the other three, with the mangling pattern varying by keyboard and language

Why the obvious fix failed

The first fix was to remove the debounce, on the theory that a delayed emission was racing the keyboard:

val results = _query.map { it.text }.distinctUntilChanged().mapLatest { … }

No change — the mangling happens between keystroke and field, not between field and search. The debounce is downstream of the problem.

The second fix was to hoist only the String:

private val _query = MutableStateFlow("")
TextField(value = query, onValueChange = { vm.onQueryChange(it) })

This made it worse and more interesting: cursor position now jumped to the end of the text on every keystroke when editing mid-string, because a String-valued TextField has no selection to preserve. The mangling rate went from 18% to 18% and a new cursor bug appeared, which is a useful signal — it means the TextFieldValue carries something load-bearing.

The third attempt was snapshotFlow to break the synchronous link:

var local by remember { mutableStateOf(TextFieldValue("")) }
TextField(value = local, onValueChange = { local = it })
LaunchedEffect(Unit) { snapshotFlow { local.text }.collect { vm.onQueryChange(it) } }

Which works — and was arrived at by accident rather than by understanding, so it is worth saying why it works below, because the reason is the whole post.

The actual mechanism

An IME does not send characters. It maintains a composing region: a span of the text buffer that is provisional, owned by the keyboard, and revised as the user types.

  keystroke     committed text    composing region     what the field shows
  f             ""                "f"                  f
  l             ""                "fl"                 fl
  i             ""                "fli"                fli
  (space)       "fli "            ""                   fli

For Latin fast-typing Gboard uses the composing region for autocorrect; for Japanese it holds the kana until conversion; for a long-press umlaut it holds the base character while the picker is open.

The composing region lives in the connection between the keyboard and the field. On Android that is an InputConnection, and the keyboard issues operations against it — setComposingText, finishComposingText, commitText — each of which assumes the buffer is in the state the previous operation left it.

A state round trip invalidates that assumption. When onValueChange goes to a MutableStateFlow and comes back through collectAsStateWithLifecycle, the field receives a value from outside the input connection. Compose's TextField then has to reconcile an externally-supplied value with a live composing region, and the only safe thing it can do is end the composition. The keyboard's next operation then applies to a buffer it no longer understands.

Why only Android. Because only Android's IME model exposes the composing region to the field this way:

  platform   IME composition reaches the field as          external value set mid-composition
  android    InputConnection ops on a live buffer          composition RESTARTS → characters lost
  ios        UITextInput marked text; UIKit reconciles     marked text preserved by UIKit
  desktop    hardware keys; no composing region at all     nothing to invalidate
  web        beforeinput/compositionstart on the DOM node  browser owns reconciliation

iOS and web have the same concept and their platform layer reconciles it for you. Desktop with a hardware keyboard has no composition at all — which is why desktop tests, where we do all our testing, could never show this.

And snapshotFlow works because it is one-directional. The field's value never comes back from outside: local state is the single writer, and the ViewModel is a reader. That is the actual fix, and "hoist the state" is the advice that breaks it — for a text field, the component's own buffer is not duplicated state, it is the input connection's view of the world.

How IME composition reaches the field on each of four platforms and what happens when an external value is set mid-composition, with only one platform restarting the composition

The fix

The field owns its buffer. The ViewModel observes committed text. Nothing writes the field's value from outside except an explicit, deliberate reset.

// feature/search/src/commonMain/kotlin/SearchScreen.kt
@Composable
fun SearchScreen(vm: SearchViewModel) {

    /**
     * LOCAL, and deliberately not hoisted. A TextField's TextFieldValue carries the IME's
     * composing region — provisional text the keyboard owns and revises between keystrokes.
     * Routing it through a StateFlow means the field receives a value from outside the input
     * connection, which ends the composition; the keyboard's next operation then applies to a
     * buffer it no longer understands. Measured: 18% of Android search sessions mangled,
     * 0.1% on every other target, because only Android's IME model surfaces the region here.
     *
     * This is the one place "hoist all state to the ViewModel" is wrong: the buffer is not a
     * duplicate of the ViewModel's state, it is the input connection's view of it.
     */
    var field by remember { mutableStateOf(TextFieldValue("")) }

    TextField(
        value = field,
        onValueChange = { field = it },        // the ONLY writer
        singleLine = true,
    )

    // One-directional: the ViewModel READS committed text. Nothing flows back into `field`.
    LaunchedEffect(Unit) {
        snapshotFlow { field.text }.collect(vm::onQueryChange)
    }

    // The one case that legitimately writes the field from outside — an explicit user action,
    // not a state sync. It CAN land mid-composition, and that is acceptable here: the user has
    // just asked for the field to be emptied, so discarding a half-composed word is the
    // intent. The distinction from the defect is that this fires on an action, not on echo.
    LaunchedEffect(Unit) {
        vm.clearRequests.collect { field = TextFieldValue("") }
    }

    val results by vm.results.collectAsStateWithLifecycle()
    ResultList(results)
}
// SearchViewModel — now a consumer of text rather than the owner of a buffer
class SearchViewModel(private val repo: SearchRepo, d: AppDispatchers) : BaseViewModel(d) {
    private val _query = MutableStateFlow("")      // String, not TextFieldValue: the ViewModel
                                                   // has no business holding a selection.
    // CONFLATED, not rendezvous: trySend on a rendezvous channel returns false and DROPS the
    // event whenever the collector is not suspended at receive — which is most of the time.
    private val _clearRequests = Channel<Unit>(Channel.CONFLATED)
    val clearRequests: Flow<Unit> = _clearRequests.receiveAsFlow()

    fun onQueryChange(text: String) { _query.value = text }
    fun onClearTapped() { _clearRequests.trySend(Unit) }

    val results = _query.debounce(300).distinctUntilChanged()
        .mapLatest { if (it.isBlank()) emptyList() else repo.search(it) }
        .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
}

And the test, which cannot be a commonTest — which is the finding, not a limitation. The bug lives in an InputConnection, and commonTest has no such thing; performTextInput commits each character outright, which the ORIGINAL code also passed. The only way to drive a composing region is an instrumented Android test holding the real connection:

// feature/search/src/androidInstrumentedTest/kotlin/SearchComposingTest.kt
//
// androidInstrumentedTest, on a device or emulator. There is no portable version of this test
// because there is no portable version of the mechanism — which is exactly why four years of
// desktop and Robolectric tests never saw it.
@Test
fun composingTextSurvivesThroughToTheViewModel() {
    composeRule.setContent { SearchScreen(vm) }
    composeRule.onNodeWithTag(SearchTags.Field).performClick()

    // The real connection the keyboard would talk to, driven directly.
    val ic = checkNotNull(inputConnectionFor(composeRule, SearchTags.Field))
    ic.setComposingText("fligh", 1)      // provisional
    ic.setComposingText("flight", 1)     // REVISED, not appended — this is the step that broke
    ic.commitText("flights", 1)

    composeRule.onNodeWithTag(SearchTags.Field).assertTextEquals("flights")
    assertEquals("flights", vm.currentQueryForTest)
}

And the output it produced:

$ ./gradlew :feature:search:connectedAndroidTest --tests '*composingTextSurvives*'
PASSED  (Pixel 7a, API 34)

$ git stash && ./gradlew :feature:search:connectedAndroidTest --tests '*composingTextSurvives*'
FAILED
  assertTextEquals: expected "flights", was "flight"
  (setComposingText("flight") applied to a buffer Compose had already finished)

One test, on the one target where the mechanism exists. The three targets that were always green stayed green and proved nothing, which is the part worth internalising.

$ ./tools/search-quality.sh --days 7
  platform    sessions    mangled    rate
  android       13,900         31     0.2%     (was 18.0%)
  ios            9,610         12     0.1%
  desktop        1,090          1     0.1%
  web            4,480          9     0.2%

Android went from 2,556 mangled sessions a week to 31, which is the same rate as every other platform — i.e. the residual is ordinary user correction, not a defect.

How we proved it

That the keyboard was not at fault. The measurement that withdrew the Gboard bug report:

  keyboard             language    mangle rate
  Gboard               English       14%
  Gboard               Japanese      91%
  Gboard               German        22%
  AOSP keyboard        English        0%
  SwiftKey             English       16%
  Samsung keyboard     English       19%

Three third-party keyboards all affected, the stock one not — which reads as "the stock keyboard is the correct one" and is actually "the stock keyboard uses the composing region least".

That the composing region is the mechanism. Logged at the input connection, which is where the answer was and where nobody had looked for four days:

# typing "fligh" fast, with the hoisted state
  setComposingText("fligh", 1)
  → onValueChange → StateFlow → recomposition → external value set
  finishComposingText()            ← Compose ended the composition
  setComposingText("flight", 1)    ← keyboard's next op, against a buffer it no longer owns
  commitText("flight")             ← commits onto a buffer that already holds "fligh"

# the same typing, with local state
  setComposingText("fligh", 1)
  setComposingText("flight", 1)
  commitText("flights")

That hoisting a String creates a second, different bug. Measured because it is the fix most people reach for and it looks simpler:

  hoisted type        mangle rate    cursor jumps to end?
  TextFieldValue            18%            no
  String                    18%            YES — every keystroke when editing mid-string
  local TextFieldValue     0.2%            no

The String version proves the value carries something load-bearing: the selection.

That desktop could never have caught it. The measurement that explains five days, and the generalisable part:

  our test suite, by target
    desktop        184 tests    ← no composing region exists on this target
    android unit    22 tests    ← Robolectric; no real InputConnection
    on device        0 tests

Zero tests ran where the mechanism exists.

That snapshotFlow is doing one specific thing. Verified rather than left as folklore, since it was found by accident:

  design                                   writers of field.value    mangle rate
  value from StateFlow                      two (user + flow)            18%
  value local, snapshotFlow out             one (user)                   0.2%
  value local, but a LaunchedEffect also
    writes it on every query change         two                         17%     ← reintroduced

Two writers is the defect. snapshotFlow is not magic; it is the direction.

What I cannot tell you is whether BasicTextField2/TextFieldState (the newer API) makes this structurally impossible. It is designed around exactly this problem and holds the buffer in a state object you pass rather than a value you set — which should remove the two-writer case by construction. We have not migrated, so I am describing its intent rather than our measurement.

And an honest consequence. The field's text is no longer in the ViewModel, so it does not survive process death with the rest of the ViewModel state. We accept that for a search box; for a form you care about, the committed text needs saving separately, and the thing you save is the committed text — never the TextFieldValue, which would restore a stale composing region.

Six keyboard and language combinations measured, the input-connection operation log with and without hoisted state, and the writer count that determines the defect

What this generalizes to

"Hoist state to the single source of truth" is good advice that assumes state has one writer. An input field with an IME does not: the keyboard holds provisional state in the field's buffer and revises it between keystrokes, so an external write — even one that restores the exact same text — invalidates the keyboard's model. The tell for this class is a component whose value is co-authored by something outside your program, and the rule for them is direction rather than ownership: let the component own its buffer and let your state observe what it commits.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS