Merge "Add extensions to TextFieldState for observing changes." into androidx-main
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt
index 3954218..d5783f1 100644
--- a/compose/foundation/foundation/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -1443,7 +1443,7 @@
     field public static final androidx.compose.foundation.text2.input.TextFieldLineLimits.SingleLine INSTANCE;
   }
 
-  @androidx.compose.foundation.ExperimentalFoundationApi public final class TextFieldState {
+  @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class TextFieldState {
     ctor public TextFieldState(optional String initialText, optional long initialSelectionInChars);
     method public inline void edit(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.text2.input.TextFieldBuffer,? extends androidx.compose.foundation.text2.input.TextEditResult> block);
     method public androidx.compose.foundation.text2.input.TextFieldCharSequence getText();
@@ -1457,9 +1457,11 @@
   }
 
   public final class TextFieldStateKt {
+    method @androidx.compose.foundation.ExperimentalFoundationApi public static suspend Object? forEachTextValue(androidx.compose.foundation.text2.input.TextFieldState, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.text2.input.TextFieldCharSequence,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<?>);
     method @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static androidx.compose.foundation.text2.input.TextFieldState rememberTextFieldState();
     method @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndPlaceCursorAtEnd(androidx.compose.foundation.text2.input.TextFieldState, String text);
     method @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndSelectAll(androidx.compose.foundation.text2.input.TextFieldState, String text);
+    method @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text2.input.TextFieldCharSequence> textAsFlow(androidx.compose.foundation.text2.input.TextFieldState);
   }
 
   @androidx.compose.foundation.ExperimentalFoundationApi @kotlin.jvm.JvmInline public final value class TextObfuscationMode {
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
index 4052364..507a250 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextField2Samples.kt
@@ -15,41 +15,151 @@
  */
 
 @file:OptIn(ExperimentalFoundationApi::class, ExperimentalFoundationApi::class)
+@file:Suppress("UNUSED_PARAMETER", "unused", "LocalVariableName", "RedundantSuspendModifier")
 
 package androidx.compose.foundation.samples
 
 import androidx.annotation.Sampled
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.text2.BasicTextField2
 import androidx.compose.foundation.text2.input.TextEditFilter
 import androidx.compose.foundation.text2.input.TextFieldState
 import androidx.compose.foundation.text2.input.delete
 import androidx.compose.foundation.text2.input.forEachChange
 import androidx.compose.foundation.text2.input.forEachChangeReversed
+import androidx.compose.foundation.text2.input.forEachTextValue
 import androidx.compose.foundation.text2.input.insert
 import androidx.compose.foundation.text2.input.rememberTextFieldState
 import androidx.compose.foundation.text2.input.selectCharsIn
+import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.foundation.text2.input.textAsFlow
 import androidx.compose.foundation.text2.input.then
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.substring
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+
+@Sampled
+fun BasicTextField2StateCompleteSample() {
+    class SearchViewModel(
+        val searchFieldState: TextFieldState = TextFieldState()
+    ) {
+        private val queryValidationRegex = """\w+""".toRegex()
+
+        // Use derived state to avoid recomposing every time the text changes, and only recompose
+        // when the input becomes valid or invalid.
+        val isQueryValid by derivedStateOf {
+            // This lambda will be re-executed every time inputState.text changes.
+            searchFieldState.text.matches(queryValidationRegex)
+        }
+
+        var searchResults: List<String> by mutableStateOf(emptyList())
+            private set
+
+        /** Called while the view model is active, e.g. from a LaunchedEffect. */
+        suspend fun run() {
+            searchFieldState.forEachTextValue { queryText ->
+                // Start a new search every time the user types something valid. If the previous
+                // search is still being processed when the text is changed, it will be cancelled
+                // and this code will run again with the latest query text.
+                if (isQueryValid) {
+                    searchResults = performSearch(query = queryText)
+                }
+            }
+        }
+
+        fun clearQuery() {
+            searchFieldState.setTextAndPlaceCursorAtEnd("")
+        }
+
+        private suspend fun performSearch(query: CharSequence): List<String> {
+            TODO()
+        }
+    }
+
+    @Composable
+    fun SearchScreen(viewModel: SearchViewModel) {
+        Column {
+            Row {
+                BasicTextField2(viewModel.searchFieldState)
+                IconButton(onClick = { viewModel.clearQuery() }) {
+                    Icon(Icons.Default.Clear, contentDescription = "clear search query")
+                }
+            }
+            if (!viewModel.isQueryValid) {
+                Text("Invalid query", style = TextStyle(color = Color.Red))
+            }
+            LazyColumn {
+                items(viewModel.searchResults) {
+                    TODO()
+                }
+            }
+        }
+    }
+}
+
+@Sampled
+fun BasicTextField2TextDerivedStateSample() {
+    class ViewModel {
+        private val inputValidationRegex = """\w+""".toRegex()
+
+        val inputState = TextFieldState()
+
+        // Use derived state to avoid recomposing every time the text changes, and only recompose
+        // when the input becomes valid or invalid.
+        val isInputValid by derivedStateOf {
+            // This lambda will be re-executed every time inputState.text changes.
+            inputState.text.matches(inputValidationRegex)
+        }
+    }
+
+    @Composable
+    fun Screen(viewModel: ViewModel) {
+        Column {
+            BasicTextField2(viewModel.inputState)
+            if (!viewModel.isInputValid) {
+                Text("Input is invalid.", style = TextStyle(color = Color.Red))
+            }
+        }
+    }
+}
 
 @Sampled
 fun BasicTextField2StateEditSample() {
-    val state = TextFieldState("hello world")
+    val state = TextFieldState("hello world!")
     state.edit {
         // Insert a comma after "hello".
-        replace(5, 5, ",") // = "hello, world"
+        insert(5, ",") // = "hello, world!"
 
-        // Delete "world".
-        replace(7, 12, "") // = "hello, "
+        // Delete the exclamation mark.
+        delete(12, 13) // = "hello, world"
 
         // Add a different name.
         append("Compose") // = "hello, Compose"
 
+        // Say goodbye.
+        replace(0, 5, "goodbye") // "goodbye, Compose"
+
         // Select the new name so the user can change it by just starting to type.
-        selectCharsIn(TextRange(7, 14)) // "hello, ̲C̲o̲m̲p̲o̲s̲e"
+        selectCharsIn(TextRange(9, 16)) // "goodbye, ̲C̲o̲m̲p̲o̲s̲e"
     }
 }
 
@@ -112,8 +222,8 @@
     printECountFilter.then(removeFirstEFilter)
 }
 
-@Composable
 @Sampled
+@Composable
 fun BasicTextField2ChangeIterationSample() {
     // Print a log message every time the text is changed.
     BasicTextField2(state = rememberTextFieldState(), filter = { _, new ->
@@ -124,8 +234,8 @@
     })
 }
 
-@Composable
 @Sampled
+@Composable
 fun BasicTextField2ChangeReverseIterationSample() {
     // Make a text field behave in "insert mode" – inserted text overwrites the text ahead of it
     // instead of being inserted.
@@ -140,4 +250,78 @@
             }
         }
     })
+}
+
+@Sampled
+fun BasicTextField2ForEachTextValueSample() {
+    class SearchViewModel {
+        val searchFieldState = TextFieldState()
+        var searchResults: List<String> by mutableStateOf(emptyList())
+            private set
+
+        /** Called while the view model is active, e.g. from a LaunchedEffect. */
+        suspend fun run() {
+            searchFieldState.forEachTextValue { queryText ->
+                // Start a new search every time the user types something. If the previous search
+                // is still being processed when the text is changed, it will be cancelled and this
+                // code will run again with the latest query text.
+                searchResults = performSearch(query = queryText)
+            }
+        }
+
+        private suspend fun performSearch(query: CharSequence): List<String> {
+            TODO()
+        }
+    }
+
+    @Composable
+    fun SearchScreen(viewModel: SearchViewModel) {
+        Column {
+            BasicTextField2(viewModel.searchFieldState)
+            LazyColumn {
+                items(viewModel.searchResults) {
+                    TODO()
+                }
+            }
+        }
+    }
+}
+
+@OptIn(FlowPreview::class)
+@Suppress("RedundantSuspendModifier")
+@Sampled
+fun BasicTextField2TextValuesSample() {
+    class SearchViewModel {
+        val searchFieldState = TextFieldState()
+        var searchResults: List<String> by mutableStateOf(emptyList())
+            private set
+
+        /** Called while the view model is active, e.g. from a LaunchedEffect. */
+        suspend fun run() {
+            searchFieldState.textAsFlow()
+                // Let fast typers get multiple keystrokes in before kicking off a search.
+                .debounce(500)
+                // collectLatest cancels the previous search if it's still running when there's a
+                // new change.
+                .collectLatest { queryText ->
+                    searchResults = performSearch(query = queryText)
+                }
+        }
+
+        private suspend fun performSearch(query: CharSequence): List<String> {
+            TODO()
+        }
+    }
+
+    @Composable
+    fun SearchScreen(viewModel: SearchViewModel) {
+        Column {
+            BasicTextField2(viewModel.searchFieldState)
+            LazyColumn {
+                items(viewModel.searchResults) {
+                    TODO()
+                }
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
index 9cd5e6a..442a7dd 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
@@ -21,25 +21,55 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.text2.input.internal.EditProcessor
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.saveable.SaverScope
 import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.text.TextRange
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
 
 /**
- * The editable text state of a text field, including both the text itself and position of the
+ * The editable text state of a text field, including both the [text] itself and position of the
  * cursor or selection.
  *
- * To change the state, call [edit].
+ * To change the text field contents programmatically, call [edit], [setTextAndSelectAll], or
+ * [setTextAndPlaceCursorAtEnd]. To observe the value of the field over time, call
+ * [forEachTextValue] or [textAsFlow].
+ *
+ * When instantiating this class from a composable, use [rememberTextFieldState] to automatically
+ * save and restore the field state. For more advanced use cases, pass [TextFieldState.Saver] to
+ * [rememberSaveable].
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2StateCompleteSample
  */
 @ExperimentalFoundationApi
+@Stable
 class TextFieldState(
     initialText: String = "",
     initialSelectionInChars: TextRange = TextRange.Zero
 ) {
-
     internal var editProcessor =
         EditProcessor(TextFieldCharSequence(initialText, initialSelectionInChars))
 
+    /**
+     * The current text and selection. This value will automatically update when the user enters
+     * text or otherwise changes the text field contents. To change it programmatically, call
+     * [edit].
+     *
+     * This is backed by snapshot state, so reading this property in a restartable function (e.g.
+     * a composable function) will cause the function to restart when the text field's value
+     * changes.
+     *
+     * To observe changes to this property outside a restartable function, see [forEachTextValue]
+     * and [textValues].
+     *
+     * @sample androidx.compose.foundation.samples.BasicTextField2TextDerivedStateSample
+     *
+     * @see edit
+     * @see forEachTextValue
+     * @see textValues
+     */
     val text: TextFieldCharSequence
         get() = editProcessor.value
 
@@ -50,6 +80,7 @@
      * [TextEditResult] for the full list of prebuilt results).
      *
      * @sample androidx.compose.foundation.samples.BasicTextField2StateEditSample
+     *
      * @see setTextAndPlaceCursorAtEnd
      * @see setTextAndSelectAll
      */
@@ -60,7 +91,7 @@
     }
 
     override fun toString(): String =
-        "TextFieldState(selection=${text.selectionInChars}, text=\"$text\")"
+        "TextFieldState(selectionInChars=${text.selectionInChars}, text=\"$text\")"
 
     @Suppress("ShowingMemberInHiddenClass")
     @PublishedApi
@@ -103,6 +134,15 @@
 }
 
 /**
+ * Returns a [Flow] of the values of [TextFieldState.text] as seen from the global snapshot.
+ * The initial value is emitted immediately when the flow is collected.
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2TextValuesSample
+ */
+@ExperimentalFoundationApi
+fun TextFieldState.textAsFlow(): Flow<TextFieldCharSequence> = snapshotFlow { text }
+
+/**
  * Create and remember a [TextFieldState]. The state is remembered using [rememberSaveable] and so
  * will be saved and restored with the composition.
  *
@@ -144,6 +184,27 @@
     }
 }
 
+/**
+ * Invokes [block] with the value of [TextFieldState.text], and every time the value is changed.
+ *
+ * The caller will be suspended until its coroutine is cancelled. If the text is changed while
+ * [block] is suspended, [block] will be cancelled and re-executed with the new value immediately.
+ * [block] will never be executed concurrently with itself.
+ *
+ * To get access to a [Flow] of [TextFieldState.text] over time, use [textAsFlow].
+ *
+ * @sample androidx.compose.foundation.samples.BasicTextField2ForEachTextValueSample
+ *
+ * @see textAsFlow
+ */
+@ExperimentalFoundationApi
+suspend fun TextFieldState.forEachTextValue(
+    block: suspend (TextFieldCharSequence) -> Unit
+): Nothing {
+    textAsFlow().collectLatest(block)
+    error("textAsFlow expected not to complete without exception")
+}
+
 @OptIn(ExperimentalFoundationApi::class)
 internal fun TextFieldState.deselect() {
     if (!text.selectionInChars.collapsed) {
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
index e2b57a2..a089951 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.text2.input.TextEditFilter
 import androidx.compose.foundation.text2.input.TextFieldBufferWithSelection
 import androidx.compose.foundation.text2.input.TextFieldCharSequence
+import androidx.compose.runtime.State
 import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -43,13 +44,17 @@
     initialValue: TextFieldCharSequence = TextFieldCharSequence("", TextRange.Zero),
 ) {
 
+    private val valueMutableState = mutableStateOf(initialValue)
+
     /**
      * The current state of the internal editing buffer as a [TextFieldCharSequence] backed by
      * snapshot state, so its readers can get updates in composition context.
      */
-    var value: TextFieldCharSequence by mutableStateOf(initialValue)
+    var value: TextFieldCharSequence by valueMutableState
         private set
 
+    val valueState: State<TextFieldCharSequence> get() = valueMutableState
+
     // The editing buffer used for applying editor commands from IME.
     internal var mBuffer: EditingBuffer = EditingBuffer(
         text = initialValue.toString(),
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
index d068e21..15de948 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
@@ -17,14 +17,23 @@
 package androidx.compose.foundation.text2.input
 
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.text.TextRange
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertFailsWith
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
 
-@OptIn(ExperimentalFoundationApi::class)
+@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class)
 @RunWith(JUnit4::class)
 class TextFieldStateTest {
 
@@ -316,4 +325,190 @@
             placeCursorAtEnd()
         }
     }
+
+    @Test
+    fun forEachValues_fires_immediately() = runTestWithSnapshotsThenCancelChildren {
+        val state = TextFieldState("hello", initialSelectionInChars = TextRange(5))
+        val texts = mutableListOf<TextFieldCharSequence>()
+
+        launch(Dispatchers.Unconfined) {
+            state.forEachTextValue { texts += it }
+        }
+
+        assertThat(texts).hasSize(1)
+        assertThat(texts.single()).isSameInstanceAs(state.text)
+        assertThat(texts.single().toString()).isEqualTo("hello")
+        assertThat(texts.single().selectionInChars).isEqualTo(TextRange(5))
+    }
+
+    @Test
+    fun forEachValue_fires_whenTextChanged() = runTestWithSnapshotsThenCancelChildren {
+        val state = TextFieldState(initialSelectionInChars = TextRange(0))
+        val texts = mutableListOf<TextFieldCharSequence>()
+        val initialText = state.text
+
+        launch(Dispatchers.Unconfined) {
+            state.forEachTextValue { texts += it }
+        }
+
+        state.edit {
+            append("hello")
+            placeCursorBeforeCharAt(0)
+        }
+
+        assertThat(texts).hasSize(2)
+        assertThat(texts.last()).isSameInstanceAs(state.text)
+        assertThat(texts.last().toString()).isEqualTo("hello")
+        assertThat(texts.last().selectionInChars).isEqualTo(initialText.selectionInChars)
+    }
+
+    @Test
+    fun forEachValue_fires_whenSelectionChanged() = runTestWithSnapshotsThenCancelChildren {
+        val state = TextFieldState("hello", initialSelectionInChars = TextRange(0))
+        val texts = mutableListOf<TextFieldCharSequence>()
+
+        launch(Dispatchers.Unconfined) {
+            state.forEachTextValue { texts += it }
+        }
+
+        state.edit {
+            placeCursorAtEnd()
+        }
+
+        assertThat(texts).hasSize(2)
+        assertThat(texts.last()).isSameInstanceAs(state.text)
+        assertThat(texts.last().toString()).isEqualTo("hello")
+        assertThat(texts.last().selectionInChars).isEqualTo(TextRange(5))
+    }
+
+    @Test
+    fun forEachValue_firesTwice_whenEditCalledTwice() = runTestWithSnapshotsThenCancelChildren {
+        val state = TextFieldState()
+        val texts = mutableListOf<TextFieldCharSequence>()
+
+        launch(Dispatchers.Unconfined) {
+            state.forEachTextValue { texts += it }
+        }
+
+        state.edit {
+            append("hello")
+            placeCursorAtEnd()
+        }
+
+        state.edit {
+            append(" world")
+            placeCursorAtEnd()
+        }
+
+        assertThat(texts).hasSize(3)
+        assertThat(texts[1].toString()).isEqualTo("hello")
+        assertThat(texts[2]).isSameInstanceAs(state.text)
+        assertThat(texts[2].toString()).isEqualTo("hello world")
+    }
+
+    @Test
+    fun forEachValue_firesOnce_whenMultipleChangesMadeInSingleEdit() =
+        runTestWithSnapshotsThenCancelChildren {
+            val state = TextFieldState()
+            val texts = mutableListOf<TextFieldCharSequence>()
+
+            launch(Dispatchers.Unconfined) {
+                state.forEachTextValue { texts += it }
+            }
+
+            state.edit {
+                append("hello")
+                append(" world")
+                placeCursorAtEnd()
+            }
+
+            assertThat(texts.last()).isSameInstanceAs(state.text)
+            assertThat(texts.last().toString()).isEqualTo("hello world")
+        }
+
+    @Test
+    fun forEachValue_fires_whenChangeMadeInSnapshotIsApplied() =
+        runTestWithSnapshotsThenCancelChildren {
+            val state = TextFieldState()
+            val texts = mutableListOf<TextFieldCharSequence>()
+
+            launch(Dispatchers.Unconfined) {
+                state.forEachTextValue { texts += it }
+            }
+
+            val snapshot = Snapshot.takeMutableSnapshot()
+            snapshot.enter {
+                state.edit {
+                    append("hello")
+                    placeCursorAtEnd()
+                }
+                assertThat(texts.isEmpty())
+            }
+            assertThat(texts.isEmpty())
+
+            snapshot.apply()
+            snapshot.dispose()
+
+            assertThat(texts.last()).isSameInstanceAs(state.text)
+        }
+
+    @Test
+    fun forEachValue_notFired_whenChangeMadeInSnapshotThenDisposed() =
+        runTestWithSnapshotsThenCancelChildren {
+            val state = TextFieldState()
+            val texts = mutableListOf<TextFieldCharSequence>()
+
+            launch(Dispatchers.Unconfined) {
+                state.forEachTextValue { texts += it }
+            }
+
+            val snapshot = Snapshot.takeMutableSnapshot()
+            snapshot.enter {
+                state.edit {
+                    append("hello")
+                    placeCursorAtEnd()
+                }
+            }
+            snapshot.dispose()
+
+            // Only contains initial value.
+            assertThat(texts).hasSize(1)
+            assertThat(texts.single().toString()).isEmpty()
+        }
+
+    @Test
+    fun forEachValue_cancelsPreviousHandler_whenChangeMadeWhileSuspended() =
+        runTestWithSnapshotsThenCancelChildren {
+            val state = TextFieldState()
+            val texts = mutableListOf<TextFieldCharSequence>()
+
+            launch(Dispatchers.Unconfined) {
+                state.forEachTextValue {
+                    texts += it
+                    awaitCancellation()
+                }
+            }
+
+            state.setTextAndPlaceCursorAtEnd("hello")
+            state.setTextAndPlaceCursorAtEnd("world")
+
+            assertThat(texts.map { it.toString() })
+                .containsExactly("", "hello", "world")
+                .inOrder()
+        }
+
+    private fun runTestWithSnapshotsThenCancelChildren(testBody: suspend TestScope.() -> Unit) {
+        val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver {
+            // This is normally done by the compose runtime.
+            Snapshot.sendApplyNotifications()
+        }
+        try {
+            runTest {
+                testBody()
+                coroutineContext.job.cancelChildren()
+            }
+        } finally {
+            globalWriteObserverHandle.dispose()
+        }
+    }
 }
\ No newline at end of file