Skip to content

Compose Multiplatform: Fixing TextField Cursor Reset and Lag in Dialogs

Yu-Chan (Henry) Huang edited this page Oct 12, 2026 · 2 revisions

This wiki page documents the technical investigation, root cause, and architectural resolution for text input lag and cursor reset issues in Compose Multiplatform dialogs (Issue #83).


1. What Happened (The Issue)

In the Account screen's "Edit Username" AlertDialog, typing inside the OutlinedTextField caused the text input cursor to unpredictably jump back to position 0 (the beginning of the string) on every keystroke.

Symptoms:

  • Newly typed characters were inserted at the front of the string instead of the cursor's current position.
  • Typing felt laggy and uncoordinated.
  • Severe text corruption occurred during fast typing or when using IME composition (e.g., CJK / Auto-correct).

2. Root Cause Analysis (Why It Happened)

Architectural Flow (Before Fix):

[User Keystroke]
       │
       ▼
[OutlinedTextField.onValueChange]
       │
       ▼
[onEvent(OnUpdateUsernameChanged)]   ──> Dispatches event to ViewModel
       │
       ▼
[AccountViewModel._state.update]     ──> Recreates top-level AccountContract.State
       │
       ▼
[StateFlow.collectAsStateWithLifecycle()] ──> Transmits state down Compose tree
       │
       ▼
[AlertDialog Layer]                  ──> Recomposes AlertDialog content
       │
       ▼
[OutlinedTextField]                  ──> Receives updated string value

Why Did the Cursor Reset to Position 0?

  1. StateFlow vs. IME Selection Buffer Race Condition: OutlinedTextField(value: String, onValueChange: (String) -> Unit) internally converts the plain string into a TextFieldValue containing text and cursor selection (TextRange). When typing, the Input Method Editor (IME) expects immediate, frame-synchronous state confirmation.
  2. Window Boundary Latency: AlertDialog operates on a separate window/popup layer in Compose Multiplatform. Driving the text field state via Keystroke -> ViewModel Event -> StateFlow -> Screen Recomposition -> Dialog Recomposition introduced a micro-frame latency across the dialog layer.
  3. Compose Fallback Behavior: When OutlinedTextField recomposes, if the incoming value: String lags behind or differs from the internal IME selection buffer state, Compose loses track of the current cursor selection and falls back to resetting selection to TextRange(0).
  4. Unnecessary Recomposition Overhead: Updating AccountContract.State on every keystroke triggered full screen state copy operations while AccountViewModel was concurrently observing user profile flows (observeUser()), magnifying recomposition overhead.

3. How It Was Resolved (The Solution)

Architectural Best Practice: State Locality

Draft text inside a temporary pop-up dialog is transient UI state. It does not need to pollute the global ViewModel state on every single keystroke. The ViewModel only needs to receive the finalized text when the user clicks "Update".

Code Implementation:

Local state isolation was applied in AccountScreen.kt using rememberSaveable:

if (state.showEditUsernameDialog) {
    // 1. Isolate text field draft state to the local Dialog composable scope
    var editUsernameText by rememberSaveable(state.showEditUsernameDialog) {
        mutableStateOf(state.editUsername)
    }

    AlertDialog(
        onDismissRequest = { onEvent(AccountContract.Event.OnDismissEditUsernameDialog) },
        title = { Text("Edit Username") },
        text = {
            Column {
                OutlinedTextField(
                    // 2. Read and write directly to local state (0ms latency, 100% IME sync)
                    value = editUsernameText,
                    onValueChange = { editUsernameText = it },
                    label = { Text("Username") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
            }
        },
        confirmButton = {
            Button(
                onClick = {
                    // 3. Dispatch ViewModel events ONLY when user confirms
                    onEvent(AccountContract.Event.OnUpdateUsernameChanged(editUsernameText))
                    onEvent(AccountContract.Event.OnUpdateUsername)
                },
                enabled = editUsernameText.isNotBlank()
            ) {
                Text("Update")
            }
        },
        dismissButton = {
            TextButton(onClick = { onEvent(AccountContract.Event.OnDismissEditUsernameDialog) }) {
                Text("Cancel")
            }
        }
    )
}

4. Key Takeaways & Best Practices

Approach State Scope Recomposition Scope IME Cursor Stability Clean Architecture
Global StateFlow (On Every Keystroke) ViewModel StateFlow Entire screen & dialog tree ❌ Unstable (cursor resets to 0) Over-exposes draft state
Local Composable State (rememberSaveable) Local Dialog Scope Isolated OutlinedTextField only ✅ 100% Stable Clean separation (ViewModel gets final result)

Guidelines for Future Features:

  • Use rememberSaveable for transient form inputs inside dialogs or pop-up sheets to preserve text during configuration changes (e.g., device rotation).
  • Defer ViewModel state dispatches until explicit user action (e.g., clicking "Save", "Update", or "Submit").
  • Key rememberSaveable to dialog visibility (state.showDialog) so local state cleanly resets whenever the dialog is re-opened.