Repository: cashapp/redwood
Pull request #528 (2022-11-21 02:37:12)

Add TextFieldState to fix edit text race conditions.
Resolves: https://github.com/cashapp/redwood/issues/380


Patch:
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
index 8348a8ed3..e5455a745 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
@@ -24,6 +24,7 @@ import com.squareup.kotlinpoet.AnnotationSpec
 import com.squareup.kotlinpoet.CodeBlock
 import com.squareup.kotlinpoet.FileSpec
 import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.INT
 import com.squareup.kotlinpoet.KModifier.INTERNAL
 import com.squareup.kotlinpoet.KModifier.OVERRIDE
 import com.squareup.kotlinpoet.KModifier.PRIVATE
@@ -41,10 +42,12 @@ class DiffProducingSunspotWidgetFactory(
   private val json: Json = Json.Default,
   private val mismatchHandler: ProtocolMismatchHandler = ProtocolMismatchHandler.Throwing,
 ) : SunspotWidgetFactory<Nothing>, DiffProducingWidget.Factory {
-  override val RedwoodLayout = DiffProducingRedwoodLayoutWidgetFactory(delegate.RedwoodLayout, json, mismatchHandler)
-  override fun SunspotBox(): SunspotBox<Nothing> = ProtocolSunspotBox(json, mismatchHandler)
-  override fun SunspotText(): SunspotText<Nothing> = ProtocolSunspotText(json, mismatchHandler)
-  override fun SunspotButton(): SunspotButton<Nothing> = ProtocolSunspotButton(json, mismatchHandler)
+  private val ids = IdAllocator()
+
+  override val RedwoodLayout = DiffProducingRedwoodLayoutWidgetFactory(delegate.RedwoodLayout, ids, json, mismatchHandler)
+  override fun SunspotBox(): SunspotBox<Nothing> = ProtocolSunspotBox(ids.next(), json, mismatchHandler)
+  override fun SunspotText(): SunspotText<Nothing> = ProtocolSunspotText(ids.next(), json, mismatchHandler)
+  override fun SunspotButton(): SunspotButton<Nothing> = ProtocolSunspotButton(ids.next(), json, mismatchHandler)
 }
 */
 internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = schema): FileSpec {
@@ -57,6 +60,11 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
         .addSuperinterface(ComposeProtocol.DiffProducingWidgetFactory)
         .primaryConstructor(
           FunSpec.constructorBuilder()
+            .apply {
+              if (schema !== host) {
+                addParameter("ids", ComposeProtocol.IdAllocator)
+              }
+            }
             .addParameter(
               ParameterSpec.builder("json", KotlinxSerialization.Json)
                 .defaultValue("%T", KotlinxSerialization.JsonDefault)
@@ -79,13 +87,24 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
             .initializer("mismatchHandler")
             .build(),
         )
+        .addProperty(
+          PropertySpec.builder("ids", ComposeProtocol.IdAllocator, PRIVATE)
+            .apply {
+              if (schema === host) {
+                initializer("%T()", ComposeProtocol.IdAllocator)
+              } else {
+                initializer("ids")
+              }
+            }
+            .build(),
+        )
         .apply {
           if (schema === host) {
             for (dependency in schema.dependencies) {
               addProperty(
                 PropertySpec.builder(dependency.name, dependency.getWidgetFactoryType().parameterizedBy(NOTHING))
                   .addModifiers(OVERRIDE)
-                  .initializer("%T(json, mismatchHandler)", dependency.diffProducingWidgetFactoryType(host))
+                  .initializer("%T(ids, json, mismatchHandler)", dependency.diffProducingWidgetFactoryType(host))
                   .build(),
               )
             }
@@ -97,7 +116,7 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
                 .addModifiers(OVERRIDE)
                 .returns(schema.widgetType(widget).parameterizedBy(NOTHING))
                 .addStatement(
-                  "return %T(json, mismatchHandler)",
+                  "return %T(ids.next(), json, mismatchHandler)",
                   schema.diffProducingWidgetType(widget, host),
                 )
                 .build(),
@@ -111,9 +130,12 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
 
 /*
 internal class DiffProducingSunspotButton(
+  public override val id: Id,
   private val json: Json,
   private val mismatchHandler: ProtocolMismatchHandler,
-) : AbstractDiffProducingWidget(3), SunspotButton<Nothing> {
+) : AbstractDiffProducingWidget, SunspotButton<Nothing> {
+  public override val type: Int get() = 3
+
   private var onClick: (() -> Unit)? = null
 
   private val serializer_0: KSerializer<String?> = json.serializersModule.serializer()
@@ -156,14 +178,19 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
       TypeSpec.classBuilder(type)
         .addModifiers(INTERNAL)
         .superclass(ComposeProtocol.AbstractDiffProducingWidget)
-        .addSuperclassConstructorParameter("%L", widget.tag)
         .addSuperinterface(widgetName.parameterizedBy(NOTHING))
         .primaryConstructor(
           FunSpec.constructorBuilder()
+            .addParameter("id", Protocol.Id)
             .addParameter("json", KotlinxSerialization.Json)
             .addParameter("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
             .build(),
         )
+        .addProperty(
+          PropertySpec.builder("id", Protocol.Id, PUBLIC, OVERRIDE)
+            .initializer("id")
+            .build(),
+        )
         .addProperty(
           PropertySpec.builder("json", KotlinxSerialization.Json, PRIVATE)
             .initializer("json")
@@ -174,6 +201,15 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
             .initializer("mismatchHandler")
             .build(),
         )
+        .addProperty(
+          PropertySpec.builder("type", INT, PUBLIC, OVERRIDE)
+            .getter(
+              FunSpec.getterBuilder()
+                .addStatement("return %L", widget.tag)
+                .build(),
+            )
+            .build(),
+        )
         .apply {
           var nextSerializerId = 0
           val serializerIds = mutableMapOf<TypeName, Int>()
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
index 087aa0e13..3a71c4414 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
@@ -28,6 +28,7 @@ import com.squareup.kotlinpoet.UNIT
 internal object Protocol {
   val Event = ClassName("app.cash.redwood.protocol", "Event")
   val EventSink = ClassName("app.cash.redwood.protocol", "EventSink")
+  val Id = ClassName("app.cash.redwood.protocol", "Id")
   val PropertyDiff = ClassName("app.cash.redwood.protocol", "PropertyDiff")
   val LayoutModifiers = ClassName("app.cash.redwood.protocol", "LayoutModifiers")
 }
@@ -37,6 +38,7 @@ internal object ComposeProtocol {
     ClassName("app.cash.redwood.protocol.compose", "AbstractDiffProducingWidget")
   val DiffProducingWidget = ClassName("app.cash.redwood.protocol.compose", "DiffProducingWidget")
   val DiffProducingWidgetFactory = DiffProducingWidget.nestedClass("Factory")
+  val IdAllocator = ClassName("app.cash.redwood.protocol.compose", "IdAllocator")
   val ProtocolMismatchHandler =
     ClassName("app.cash.redwood.protocol.compose", "ProtocolMismatchHandler")
 }
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/AbstractDiffProducingWidget.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/AbstractDiffProducingWidget.kt
index 724c11a98..c13a0f972 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/AbstractDiffProducingWidget.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/AbstractDiffProducingWidget.kt
@@ -15,8 +15,6 @@
  */
 package app.cash.redwood.protocol.compose
 
-import app.cash.redwood.protocol.Event
-import app.cash.redwood.protocol.Id
 import app.cash.redwood.protocol.LayoutModifiers
 import app.cash.redwood.protocol.PropertyDiff
 import app.cash.redwood.widget.Widget.Children
@@ -24,15 +22,7 @@ import app.cash.redwood.widget.Widget.Children
 /**
  * @suppress For generated code usage only.
  */
-public abstract class AbstractDiffProducingWidget(
-  public val type: Int,
-) : DiffProducingWidget {
-  override val value: Nothing
-    get() = throw AssertionError()
-
-  public var id: Id = Id(ULong.MAX_VALUE)
-    internal set
-
+public abstract class AbstractDiffProducingWidget : DiffProducingWidget {
   @Suppress("PropertyName") // Avoiding potential collision with subtype properties.
   internal lateinit var _diffAppender: DiffAppender
 
@@ -47,6 +37,4 @@ public abstract class AbstractDiffProducingWidget(
   protected fun diffProducingWidgetChildren(tag: UInt): Children<Nothing> {
     return DiffProducingWidgetChildren(id, tag, _diffAppender)
   }
-
-  public abstract fun sendEvent(event: Event)
 }
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidget.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidget.kt
index eaaa6da61..aeb03026d 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidget.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidget.kt
@@ -15,6 +15,8 @@
  */
 package app.cash.redwood.protocol.compose
 
+import app.cash.redwood.protocol.Event
+import app.cash.redwood.protocol.Id
 import app.cash.redwood.widget.Widget
 
 /**
@@ -22,6 +24,14 @@ import app.cash.redwood.widget.Widget
  * based on its properties.
  */
 public interface DiffProducingWidget : Widget<Nothing> {
+  public val id: Id
+  public val type: Int
+
+  override val value: Nothing
+    get() = throw AssertionError()
+
+  public fun sendEvent(event: Event)
+
   /**
    * Marker interface for types whose functions create [DiffProducingWidget]s.
    */
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetChildren.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetChildren.kt
index 600322028..6c00572c3 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetChildren.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetChildren.kt
@@ -27,7 +27,7 @@ internal class DiffProducingWidgetChildren(
   val ids = mutableListOf<Id>()
 
   override fun insert(index: Int, widget: Widget<Nothing>) {
-    widget as AbstractDiffProducingWidget
+    widget as DiffProducingWidget
     ids.add(index, widget.id)
     diffAppender.append(ChildrenDiff.Insert(id, tag, widget.id, widget.type, index))
   }
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/IdAllocator.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/IdAllocator.kt
new file mode 100644
index 000000000..bd215d1b7
--- /dev/null
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/IdAllocator.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.protocol.compose
+
+import app.cash.redwood.protocol.Id
+
+/**
+ * @suppress For generated code usage only.
+ */
+public class IdAllocator {
+  private var nextValue = Id.Root.value + 1U
+
+  public fun next(): Id {
+    val value = nextValue
+    nextValue = value + 1U
+    return Id(value)
+  }
+}
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
index b405c39cc..19be40156 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
@@ -67,7 +67,6 @@ internal class ProtocolApplier(
     DiffProducingWidgetChildren(Id.Root, RootChildrenTag, diffAppender),
   ),
 ) {
-  private var nextId = Id.Root.next()
   internal val nodes = mutableMapOf(Id.Root to root)
   private var closed = false
 
@@ -84,14 +83,10 @@ internal class ProtocolApplier(
       instance.children = instance.accessor!!.invoke(current)
       instance.accessor = null
     } else {
-      val id = nextId
-      nextId = id.next()
-
       instance as AbstractDiffProducingWidget
-      instance.id = id
       instance._diffAppender = diffAppender
 
-      nodes[id] = instance
+      nodes[instance.id] = instance
 
       val current = current as _ChildrenWidget
       current.children!!.insert(index, instance)
@@ -126,6 +121,4 @@ internal class ProtocolApplier(
   override fun onClear() {
     closed = true
   }
-
-  private fun Id.next(): Id = Id(value + 1UL)
 }
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt
index ab5b00b0d..ef6401bbe 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolRedwoodComposition.kt
@@ -103,7 +103,7 @@ private class DiffProducingRedwoodComposition(
 
     onEvent.sendEvent(event)
 
-    val node = applier.nodes[event.id] as AbstractDiffProducingWidget?
+    val node = applier.nodes[event.id] as DiffProducingWidget?
     if (node == null) {
       // TODO how to handle race where an incoming event targets this removed node?
       throw IllegalArgumentException("Unknown node ${event.id} for event with tag ${event.tag}")
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
similarity index 88%
rename from redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt
rename to redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
index eee5a4c4c..78a2de3a0 100644
--- a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
@@ -22,17 +22,25 @@ import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.DiffSink
 import app.cash.redwood.protocol.EventSink
 import app.cash.redwood.protocol.Id
+import app.cash.redwood.protocol.LayoutModifiers
 import app.cash.redwood.protocol.PropertyDiff
 import app.cash.redwood.widget.Widget
 import kotlinx.serialization.json.JsonArray
 
-public class ProtocolDisplay<T : Any>(
+/**
+ * Bridges the serialized Redwood protocol back to widgets on the display side.
+ *
+ * This type will consume [Diff]s and apply their [ChildrenDiff] operations to the widget tree.
+ * [PropertyDiff]s and [LayoutModifiers]s are forwarded to their respective widgets. Events from
+ * widgets are forwarded to [eventSink].
+ */
+public class ProtocolBridge<T : Any>(
   container: Widget.Children<T>,
   private val factory: DiffConsumingWidget.Factory<T>,
   private val eventSink: EventSink,
 ) : DiffSink {
   private val nodes = mutableMapOf(
-    Id.Root to Node(ProtocolDisplayRoot(container), Id.Root, container),
+    Id.Root to Node(DiffConsumingProtocolRoot(container), Id.Root, container),
   )
 
   override fun sendDiff(diff: Diff) {
@@ -91,7 +99,7 @@ private class Node<T : Any>(
     get() = _childIds ?: mutableListOf<Id>().also { _childIds = it }
 }
 
-private class ProtocolDisplayRoot<T : Any>(
+private class DiffConsumingProtocolRoot<T : Any>(
   private val children: Widget.Children<T>,
 ) : DiffConsumingWidget<T> {
   override var layoutModifiers: LayoutModifier
diff --git a/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt b/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
index 510fbb531..20f2f9b4e 100644
--- a/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
+++ b/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
@@ -39,6 +39,7 @@ public fun <T : Any> TreehouseContent(
   val rememberedContent = rememberUpdatedState(content)
   val treehouseView = remember {
     object : TreehouseView<T> {
+      override var codeListener = TreehouseView.CodeListener()
       override val boundContent: TreehouseView.Content<T> get() = rememberedContent.value
       override val children = ComposeWidgetChildren()
       override val hostConfiguration = MutableStateFlow(hostConfiguration)
diff --git a/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt b/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
index 1c65d709f..d0212e8a2 100644
--- a/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
+++ b/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
@@ -22,6 +22,7 @@ import android.content.res.Configuration.UI_MODE_NIGHT_MASK
 import android.content.res.Configuration.UI_MODE_NIGHT_YES
 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
 import android.widget.FrameLayout
+import app.cash.redwood.treehouse.TreehouseView.CodeListener
 import app.cash.redwood.widget.ViewGroupChildren
 import app.cash.redwood.widget.Widget
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,6 +33,7 @@ public class TreehouseWidgetView<T : Any>(
   context: Context,
   private val treehouseApp: TreehouseApp<T>,
 ) : FrameLayout(context), TreehouseView<T> {
+  public override var codeListener: CodeListener = CodeListener()
   private var content: TreehouseView.Content<T>? = null
 
   override val boundContent: TreehouseView.Content<T>?
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index 02fe00141..910130a38 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -18,7 +18,7 @@ package app.cash.redwood.treehouse
 import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.EventSink
 import app.cash.redwood.protocol.widget.DiffConsumingWidget.Factory
-import app.cash.redwood.protocol.widget.ProtocolDisplay
+import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.Widget
 import app.cash.zipline.Zipline
 import app.cash.zipline.ZiplineService
@@ -117,7 +117,7 @@ public class TreehouseApp<T : Any> internal constructor(
     } else {
       // If we're waiting for code to load, show a loading indicator until it's ready.
       withContext(dispatchers.ui) {
-        spec.viewBinder.codeLoading(view)
+        view.codeListener.codeLoading(view)
       }
     }
   }
@@ -170,7 +170,7 @@ public class TreehouseApp<T : Any> internal constructor(
       }
 
       @Suppress("UNCHECKED_CAST") // We don't have a type parameter for the widget type.
-      val display = ProtocolDisplay(
+      val bridge = ProtocolBridge(
         container = view.children as Widget.Children<Any>,
         factory = spec.viewBinder.widgetFactory(
           view,
@@ -191,12 +191,12 @@ public class TreehouseApp<T : Any> internal constructor(
               view.reset()
 
               when {
-                isInitialLaunch -> spec.viewBinder.beforeInitialCode(view)
-                else -> spec.viewBinder.beforeUpdatedCode(view)
+                isInitialLaunch -> view.codeListener.beforeInitialCode(view)
+                else -> view.codeListener.beforeUpdatedCode(view)
               }
             }
 
-            display.sendDiff(diff)
+            bridge.sendDiff(diff)
           }
         }
       }
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
index c5882ea77..758615e2d 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
@@ -23,6 +23,7 @@ public interface TreehouseView<T : Any> {
   public val boundContent: Content<T>?
   public val children: Widget.Children<*>
   public val hostConfiguration: StateFlow<HostConfiguration>
+  public val codeListener: CodeListener
 
   /** Invoked when new code is loaded. This should at minimum clear all [children]. */
   public fun reset()
@@ -30,4 +31,15 @@ public interface TreehouseView<T : Any> {
   public fun interface Content<T : Any> {
     public fun get(app: T): ZiplineTreehouseUi
   }
+
+  public open class CodeListener {
+    /** Show a spinner when a view is waiting for the code to load. */
+    public fun codeLoading(view: TreehouseView<*>) {}
+
+    /** Clear the loading indicator when the first code is loaded. */
+    public fun beforeInitialCode(view: TreehouseView<*>) {}
+
+    /** Clear the previous UI and show a quick animation for subsequent code updates. */
+    public fun beforeUpdatedCode(view: TreehouseView<*>) {}
+  }
 }
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/ViewBinder.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/ViewBinder.kt
index d8b97aa1f..62fe3048e 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/ViewBinder.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/ViewBinder.kt
@@ -20,15 +20,6 @@ import app.cash.redwood.protocol.widget.ProtocolMismatchHandler
 import kotlinx.serialization.json.Json
 
 public interface ViewBinder {
-  /** Show a spinner when a view is waiting for the code to load. */
-  public fun codeLoading(view: TreehouseView<*>) {}
-
-  /** Clear the loading indicator when the first code is loaded. */
-  public fun beforeInitialCode(view: TreehouseView<*>) {}
-
-  /** Clear the previous UI and show a quick animation for subsequent code updates. */
-  public fun beforeUpdatedCode(view: TreehouseView<*>) {}
-
   /** Returns a widget factory for encoding and decoding changes to the contents of [view]. */
   public fun widgetFactory(
     view: TreehouseView<*>,
diff --git a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
index 733ca9a0c..9b90f507b 100644
--- a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
+++ b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.treehouse
 
+import app.cash.redwood.treehouse.TreehouseView.CodeListener
 import app.cash.redwood.widget.UIViewChildren
 import app.cash.redwood.widget.Widget
 import kotlinx.cinterop.ObjCAction
@@ -33,6 +34,7 @@ public class TreehouseUIKitView<T : Any>(
   private val treehouseApp: TreehouseApp<T>,
 ) : TreehouseView<T> {
   public val view: UIView = RootUiView(this)
+  public override var codeListener: CodeListener = CodeListener()
   private var content: TreehouseView.Content<T>? = null
 
   override val boundContent: TreehouseView.Content<T>?
diff --git a/samples/counter/android-composeui/src/main/java/example/android/MainActivity.kt b/samples/counter/android-composeui/src/main/java/example/android/MainActivity.kt
index f80b0328f..3e6f721d2 100644
--- a/samples/counter/android-composeui/src/main/java/example/android/MainActivity.kt
+++ b/samples/counter/android-composeui/src/main/java/example/android/MainActivity.kt
@@ -22,7 +22,7 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.runtime.Composable
 import app.cash.redwood.compose.AndroidUiDispatcher
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
-import app.cash.redwood.protocol.widget.ProtocolDisplay
+import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.compose.ComposeWidgetChildren
 import example.android.sunspot.AndroidSunspotWidgetFactory
 import example.shared.Counter
@@ -60,13 +60,13 @@ class MainActivity : AppCompatActivity() {
     )
 
     val factory = DiffConsumingSunspotWidgetFactory(AndroidSunspotWidgetFactory())
-    val display = ProtocolDisplay(
+    val bridge = ProtocolBridge(
       container = composeChildren,
       factory = factory,
       eventSink = composition,
     )
 
-    composition.start(display)
+    composition.start(bridge)
     composition.setContent(content)
 
     return composeChildren
diff --git a/samples/counter/android-views/src/main/java/example/android/MainActivity.kt b/samples/counter/android-views/src/main/java/example/android/MainActivity.kt
index 0e52422b0..011658b0e 100644
--- a/samples/counter/android-views/src/main/java/example/android/MainActivity.kt
+++ b/samples/counter/android-views/src/main/java/example/android/MainActivity.kt
@@ -23,7 +23,7 @@ import android.widget.LinearLayout.LayoutParams
 import androidx.appcompat.app.AppCompatActivity
 import app.cash.redwood.compose.AndroidUiDispatcher
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
-import app.cash.redwood.protocol.widget.ProtocolDisplay
+import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.ViewGroupChildren
 import example.android.sunspot.AndroidSunspotWidgetFactory
 import example.shared.Counter
@@ -52,13 +52,13 @@ class MainActivity : AppCompatActivity() {
     setContentView(root)
 
     val factory = DiffConsumingSunspotWidgetFactory(AndroidSunspotWidgetFactory(this))
-    val display = ProtocolDisplay(
+    val bridge = ProtocolBridge(
       container = ViewGroupChildren(root),
       factory = factory,
       eventSink = composition,
     )
 
-    composition.start(display)
+    composition.start(bridge)
 
     composition.setContent {
       Counter()
diff --git a/samples/counter/browser/src/main/kotlin/example/browser/main.kt b/samples/counter/browser/src/main/kotlin/example/browser/main.kt
index 158dd1a67..00b10615a 100644
--- a/samples/counter/browser/src/main/kotlin/example/browser/main.kt
+++ b/samples/counter/browser/src/main/kotlin/example/browser/main.kt
@@ -17,7 +17,7 @@ package example.browser
 
 import app.cash.redwood.compose.WindowAnimationFrameClock
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
-import app.cash.redwood.protocol.widget.ProtocolDisplay
+import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.HTMLElementChildren
 import example.browser.sunspot.HtmlSunspotNodeFactory
 import example.shared.Counter
@@ -41,13 +41,13 @@ fun main() {
 
   val content = document.getElementById("content")!! as HTMLElement
   val factory = DiffConsumingSunspotWidgetFactory(HtmlSunspotNodeFactory(document))
-  val display = ProtocolDisplay(
+  val bridge = ProtocolBridge(
     container = HTMLElementChildren(content),
     factory = factory,
     eventSink = composition,
   )
 
-  composition.start(display)
+  composition.start(bridge)
 
   composition.setContent {
     Counter()
diff --git a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
index 371d1c4ba..0b4822f97 100644
--- a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
+++ b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
@@ -17,7 +17,7 @@ package example.ios
 
 import androidx.compose.runtime.BroadcastFrameClock
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
-import app.cash.redwood.protocol.widget.ProtocolDisplay
+import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.UIViewChildren
 import example.ios.sunspot.IosSunspotNodeFactory
 import example.shared.Counter
@@ -50,13 +50,13 @@ class CounterViewControllerDelegate(
       insert = { view, index -> root.insertArrangedSubview(view, index.convert()) },
     )
     val factory = DiffConsumingSunspotWidgetFactory(IosSunspotNodeFactory)
-    val display = ProtocolDisplay(
+    val bridge = ProtocolBridge(
       container = children,
       factory = factory,
       eventSink = composition,
     )
 
-    composition.start(display)
+    composition.start(bridge)
 
     composition.setContent {
       Counter()
diff --git a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
index be92ff58d..8a2e9b515 100644
--- a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
+++ b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
@@ -23,7 +23,7 @@ import example.schema.widget.EmojiSearchWidgetFactory
 class AndroidEmojiSearchWidgetFactory<T : Any>(private val treehouseApp: TreehouseApp<T>) : EmojiSearchWidgetFactory<@Composable () -> Unit> {
   override val RedwoodLayout = ComposeRedwoodLayoutWidgetFactory()
   override fun LazyColumn() = ComposeUiLazyColumn(treehouseApp)
-  override fun TextInput() = ComposeUiTextInput()
+  override fun TextInput() = ComposeUiTextInput(treehouseApp.dispatchers)
   override fun Text() = ComposeUiText()
   override fun Image() = ComposeUiImage()
 }
diff --git a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/ComposeUiTextInput.kt b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/ComposeUiTextInput.kt
index 726678d02..81dbbbcf6 100644
--- a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/ComposeUiTextInput.kt
+++ b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/ComposeUiTextInput.kt
@@ -15,44 +15,98 @@
  */
 package app.cash.zipline.samples.emojisearch.composeui
 
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
 import androidx.compose.material.Text
 import androidx.compose.material.TextField
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.treehouse.TreehouseDispatchers
 import example.schema.widget.TextInput
+import example.values.TextFieldState
+import kotlin.coroutines.EmptyCoroutineContext
 
-class ComposeUiTextInput : TextInput<@Composable () -> Unit> {
+class ComposeUiTextInput(
+  private val dispatchers: TreehouseDispatchers,
+) : TextInput<@Composable () -> Unit> {
+  private var state by mutableStateOf(TextFieldState())
   private var hint by mutableStateOf("")
-  private var text by mutableStateOf("")
-  private var onTextChanged: ((String) -> Unit)? = null
+  private var onChange: ((TextFieldState) -> Unit)? = null
+  private var updating = false
 
   override var layoutModifiers: LayoutModifier = LayoutModifier
 
   override val value = @Composable {
+    // Preserve 'composition' and other state properties that we don't modify.
+    var textFieldValue by remember { mutableStateOf(TextFieldValue()) }
+
     TextField(
-      value = text,
-      onValueChange = { onTextChanged?.invoke(it) },
-      label = { Text(hint) },
+      value = textFieldValue.copy(
+        text = state.text,
+        selection = TextRange(state.selectionStart, state.selectionEnd),
+      ),
+      label = {
+        if (hint.isNotEmpty()) {
+          Text(hint)
+        }
+      },
       maxLines = 2,
+      onValueChange = { newValue ->
+        textFieldValue = newValue
+        stateChanged(newValue)
+      },
     )
   }
 
+  /**
+   * Handle state changes from Treehouse. These will often be based on out-of-date user edits,
+   * in which case we discard the Treehouse update. Eventually the user will stop typing and we'll
+   * make the update without interrupting them.
+   */
+  override fun state(state: TextFieldState) {
+    if (state.userEditCount < this.state.userEditCount) return
+
+    check(!updating)
+    try {
+      updating = true
+      this.state = state
+    } finally {
+      updating = false
+    }
+  }
+
   override fun hint(hint: String) {
     this.hint = hint
   }
 
-  override fun text(text: String) {
-    this.text = text
+  override fun onChange(onChange: ((TextFieldState) -> Unit)?) {
+    this.onChange = onChange
   }
 
-  override fun onTextChanged(onTextChanged: ((String) -> Unit)?) {
-    this.onTextChanged = onTextChanged
+  /**
+   * Handle state changes from the user. When these happen we save the new state, which has a
+   * new [TextFieldState.userEditCount]. That way we can ignore updates that are based on stale
+   * data.
+   */
+  private fun stateChanged(value: TextFieldValue) {
+    // Ignore this update if it isn't a user edit.
+    if (updating) return
+
+    val newState = state.userEdit(
+      text = value.text,
+      selectionStart = value.selection.start,
+      selectionEnd = value.selection.end,
+    )
+    if (!state.contentEquals(newState)) {
+      state = newState
+      dispatchers.zipline.dispatch(
+        EmptyCoroutineContext,
+        Runnable { onChange?.invoke(newState) },
+      )
+    }
   }
 }
diff --git a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
index 37b07165a..6d06278a0 100644
--- a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
+++ b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
@@ -31,7 +31,7 @@ class AndroidViewEmojiSearchWidgetFactory<T : Any>(
 ) : EmojiSearchWidgetFactory<View> {
   override val RedwoodLayout = ViewRedwoodLayoutWidgetFactory(context)
   override fun LazyColumn() = ViewLazyColumn(treehouseApp, RecyclerView(context))
-  override fun TextInput() = ViewTextInput(EditText(context))
+  override fun TextInput() = ViewTextInput(context, treehouseApp.dispatchers)
   override fun Text() = ViewText(TextView(context))
   override fun Image() = ViewImage(ImageView(context))
 }
diff --git a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewLazyColumn.kt b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewLazyColumn.kt
index bb3bf9b1e..7c2bb73e3 100644
--- a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewLazyColumn.kt
+++ b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewLazyColumn.kt
@@ -63,7 +63,7 @@ class ViewLazyColumn<T : Any>(
   override fun intervals(intervals: List<LazyListIntervalContent>) {
     adapter.submitList(
       intervals.flatMap { interval ->
-        (0 until interval.count).map { index ->
+        List(interval.count) { index ->
           LazyContentItem(index, interval.itemProvider)
         }
       },
diff --git a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewTextInput.kt b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewTextInput.kt
index 5669af10c..b4874dcbf 100644
--- a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewTextInput.kt
+++ b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/ViewTextInput.kt
@@ -15,40 +15,99 @@
  */
 package app.cash.zipline.samples.emojisearch.views
 
+import android.content.Context
 import android.text.TextWatcher
 import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
 import android.widget.EditText
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatEditText
+import androidx.core.widget.addTextChangedListener
 import androidx.core.widget.doOnTextChanged
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.treehouse.TreehouseDispatchers
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
 import example.schema.widget.TextInput
+import example.values.TextFieldState
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineDispatcher
 
 class ViewTextInput(
-  override val value: EditText,
+  context: Context,
+  private val dispatchers: TreehouseDispatchers,
 ) : TextInput<View> {
+  private var state = TextFieldState()
+  private var onChange: ((TextFieldState) -> Unit)? = null
+  private var updating = false
+
+  override val value = object : TextInputEditText(context) {
+    init {
+      addTextChangedListener(
+        onTextChanged = { _, _, _, _ ->
+          stateChanged(this)
+        }
+      )
+      maxLines = 2
+    }
+
+    override fun onSelectionChanged(selStart: Int, selEnd: Int) {
+      super.onSelectionChanged(selStart, selEnd)
+      stateChanged(this)
+    }
+  }
   override var layoutModifiers: LayoutModifier = LayoutModifier
 
-  init {
-    value.maxLines = 2
+  /**
+   * Handle state changes from Treehouse. These will often be based on out-of-date user edits,
+   * in which case we discard the Treehouse update. Eventually the user will stop typing and we'll
+   * make the update without interrupting them.
+   */
+  override fun state(state: TextFieldState) {
+    if (state.userEditCount < this.state.userEditCount) return
+
+    check(!updating)
+    try {
+      updating = true
+      this.state = state
+      value.setText(state.text)
+      value.setSelection(state.selectionStart, state.selectionEnd)
+    } finally {
+      updating = false
+    }
   }
 
   override fun hint(hint: String) {
     value.hint = hint
   }
 
-  override fun text(text: String) {
-    value.setText(text)
-    value.setSelection(text.length)
+  override fun onChange(onChange: ((TextFieldState) -> Unit)?) {
+    this.onChange = onChange
   }
 
-  private var watcher: TextWatcher? = null
-  override fun onTextChanged(onTextChanged: ((String) -> Unit)?) {
-    watcher = if (onTextChanged == null) {
-      watcher?.let(value::removeTextChangedListener)
-      null
-    } else {
-      value.doOnTextChanged { _, _, _, _ ->
-        onTextChanged.invoke(value.text.toString())
-      }
+  /**
+   * Handle state changes from the user. When these happen we save the new state, which has a
+   * new [TextFieldState.userEditCount]. That way we can ignore updates that are based on stale
+   * data.
+   */
+  private fun stateChanged(editText: EditText) {
+    // Ignore this update if it isn't a user edit.
+    if (updating) return
+
+    val newState = state.userEdit(
+      text = editText.text?.toString().orEmpty(),
+      selectionStart = editText.selectionStart,
+      selectionEnd = editText.selectionEnd,
+    )
+    if (!state.contentEquals(newState)) {
+      state = newState
+      dispatchers.zipline.dispatch(
+        EmptyCoroutineContext,
+        Runnable { onChange?.invoke(newState) },
+      )
     }
   }
 }
diff --git a/samples/emoji-search/ios/app/EmojiSearchApp/TextInputBinding.swift b/samples/emoji-search/ios/app/EmojiSearchApp/TextInputBinding.swift
index 383232641..a48db7315 100644
--- a/samples/emoji-search/ios/app/EmojiSearchApp/TextInputBinding.swift
+++ b/samples/emoji-search/ios/app/EmojiSearchApp/TextInputBinding.swift
@@ -24,24 +24,24 @@ class TextInputBinding: WidgetTextInput {
         view.autocapitalizationType = .none
         return view
     }()
+    
+    func state(state: ValuesTextFieldState) {
+        root.text = state.text
+    }
 
     func hint(hint: String) {
         root.placeholder = hint
     }
 
-    func onTextChanged(onTextChanged: ((String) -> Void)? = nil) {
+    func onChange(onChange: ((ValuesTextFieldState) -> Void)? = nil) {
         let identifier = UIAction.Identifier("TextInputBinding.onTextChanged")
 
         root.removeAction(identifiedBy: identifier, for: .editingChanged)
         root.addAction(UIAction(identifier: identifier, handler: { [unowned self] _ in
-            onTextChanged?(self.root.text ?? "")
+            onChange?(ValuesTextFieldState(text: self.root.text ?? "", selectionStart: 0, selectionEnd: 0, userEditCount: 0))
         }), for: .editingChanged)
     }
 
-    func text(text: String) {
-        root.text = text
-    }
-
     var layoutModifiers: Redwood_runtimeLayoutModifier = ExposedKt.layoutModifier()
     var value: Any { root }
 }
diff --git a/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/models.kt b/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/models.kt
index d598dcd97..c3d57444d 100644
--- a/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/models.kt
+++ b/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/models.kt
@@ -15,19 +15,20 @@
  */
 package app.cash.zipline.samples.emojisearch
 
+import example.values.TextFieldState
 import kotlinx.serialization.Serializable
 
 @Serializable
 sealed class EmojiSearchEvent {
   @Serializable
   class SearchTermEvent(
-    val searchTerm: String,
+    val searchTerm: TextFieldState,
   ) : EmojiSearchEvent()
 }
 
 @Serializable
 data class EmojiSearchViewModel(
-  val searchTerm: String,
+  val searchTerm: TextFieldState,
   val images: List<EmojiImage>,
 )
 
diff --git a/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearch.kt b/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearch.kt
index c9bbadfea..7e630555a 100644
--- a/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearch.kt
+++ b/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearch.kt
@@ -46,9 +46,11 @@ class EmojiSearchTreehouseUi(
       horizontalAlignment = CrossAxisAlignment.Stretch,
     ) {
       TextInput(
-        text = viewModel.searchTerm,
+        state = viewModel.searchTerm,
         hint = "Search",
-        onTextChanged = { onEvent(SearchTermEvent(it)) },
+        onChange = {
+          onEvent(SearchTermEvent(it))
+        },
       )
       LazyColumn {
         items(viewModel.images) { image ->
diff --git a/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/RealEmojiSearchPresenter.kt b/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/RealEmojiSearchPresenter.kt
index 7faaeb560..5ff82849a 100644
--- a/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/RealEmojiSearchPresenter.kt
+++ b/samples/emoji-search/presenters/src/jsMain/kotlin/app/cash/zipline/samples/emojisearch/RealEmojiSearchPresenter.kt
@@ -19,6 +19,7 @@ import app.cash.redwood.treehouse.ZiplineTreehouseUi
 import app.cash.redwood.treehouse.asZiplineTreehouseUi
 import app.cash.zipline.samples.emojisearch.EmojiSearchEvent.SearchTermEvent
 import example.schema.compose.DiffProducingEmojiSearchWidgetFactory
+import example.values.TextFieldState
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.channelFlow
@@ -31,13 +32,13 @@ class RealEmojiSearchPresenter(
   private val json: Json,
 ) : EmojiSearchPresenter {
   private var emojis = listOf<EmojiImage>()
-  private var latestSearchTerm = ""
+  private var latestSearchTerm = TextFieldState()
 
   private val loadingImage = EmojiImage(
     label = "watch",
     url = "https://github.githubassets.com/images/icons/emoji/unicode/231a.png?v8",
   )
-  private val initialViewModel = EmojiSearchViewModel("", listOf(loadingImage))
+  private val initialViewModel = EmojiSearchViewModel(latestSearchTerm, listOf(loadingImage))
 
   override fun launch(): ZiplineTreehouseUi {
     val events = MutableSharedFlow<EmojiSearchEvent>(extraBufferCapacity = Int.MAX_VALUE)
@@ -82,7 +83,7 @@ class RealEmojiSearchPresenter(
   }
 
   private fun produceModel(): EmojiSearchViewModel {
-    val searchTerms = latestSearchTerm.split(" ")
+    val searchTerms = latestSearchTerm.text.split(" ")
     val filteredImages = emojis
       .filter { image ->
         searchTerms.all { image.label.contains(it, ignoreCase = true) }
diff --git a/samples/emoji-search/schema/src/main/kotlin/example/schema/schema.kt b/samples/emoji-search/schema/src/main/kotlin/example/schema/schema.kt
index 206b36e41..72ef521ed 100644
--- a/samples/emoji-search/schema/src/main/kotlin/example/schema/schema.kt
+++ b/samples/emoji-search/schema/src/main/kotlin/example/schema/schema.kt
@@ -16,11 +16,13 @@
 package example.schema
 
 import app.cash.redwood.layout.RedwoodLayout
+import app.cash.redwood.schema.Default
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Schema.Dependency
 import app.cash.redwood.schema.Widget
 import example.values.LazyListIntervalContent
+import example.values.TextFieldState
 
 @Schema(
   members = [
@@ -37,9 +39,12 @@ interface EmojiSearch
 
 @Widget(1)
 data class TextInput(
-  @Property(1) val hint: String,
-  @Property(2) val text: String,
-  @Property(3) val onTextChanged: (String) -> Unit,
+  @Property(1) @Default("TextFieldState()")
+  val state: TextFieldState,
+  @Property(2) @Default("\"\"")
+  val hint: String,
+  @Property(3) @Default("null")
+  val onChange: (TextFieldState) -> Unit,
 )
 
 @Widget(2)
diff --git a/samples/emoji-search/values/src/commonMain/kotlin/example/values/TextFieldState.kt b/samples/emoji-search/values/src/commonMain/kotlin/example/values/TextFieldState.kt
new file mode 100644
index 000000000..30c2bc33c
--- /dev/null
+++ b/samples/emoji-search/values/src/commonMain/kotlin/example/values/TextFieldState.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.values
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TextFieldState(
+  val text: String = "",
+  val selectionStart: Int = 0,
+  val selectionEnd: Int = 0,
+  val userEditCount: Long = 0L,
+) {
+  init {
+    require(selectionStart in 0..text.length)
+    require(selectionEnd in 0..text.length)
+  }
+
+  /** Returns a copy of the state initiated by a user edit. */
+  fun userEdit(
+    text: String = this.text,
+    selectionStart: Int = this.selectionStart,
+    selectionEnd: Int = this.selectionEnd,
+  ) = copy(
+    text = text,
+    selectionStart = selectionStart.coerceIn(0, text.length),
+    selectionEnd = selectionEnd.coerceIn(0, text.length),
+    userEditCount = userEditCount + 1L,
+  )
+
+  /**
+   * Returns true if [other] and this are equal ignoring version metadata.
+   * Use this to skip no-op user edits.
+   */
+  fun contentEquals(other: TextFieldState): Boolean =
+    copy(userEditCount = other.userEditCount) == other
+}


Test patch:
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
index 26fc97e64..0b7abf6dc 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
@@ -47,7 +47,6 @@ class DiffProducingWidgetFactoryTest {
     val diffProducingWidget = textInput as AbstractDiffProducingWidget
     val diffSink = RecordingDiffSink()
     val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget.id = Id(1U)
     diffProducingWidget._diffAppender = diffAppender
 
     textInput.customType(10.seconds)
@@ -73,7 +72,6 @@ class DiffProducingWidgetFactoryTest {
     val diffProducingWidget = button as AbstractDiffProducingWidget
     val diffSink = RecordingDiffSink()
     val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget.id = Id(1U)
     diffProducingWidget._diffAppender = diffAppender
 
     button.layoutModifiers = with(object : TestScope {}) {
@@ -115,7 +113,6 @@ class DiffProducingWidgetFactoryTest {
     val diffProducingWidget = button as AbstractDiffProducingWidget
     val diffSink = RecordingDiffSink()
     val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget.id = Id(1U)
     diffProducingWidget._diffAppender = diffAppender
 
     button.layoutModifiers = with(object : TestScope {}) {
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplayTest.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
similarity index 90%
rename from redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplayTest.kt
rename to redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
index 2ba667b9c..504fe697d 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplayTest.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
@@ -25,9 +25,9 @@ import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 
-class ProtocolDisplayTest {
+class ProtocolBridgeTest {
   @Test fun insertRootIdThrows() {
-    val protocolDisplay = ProtocolDisplay(
+    val bridge = ProtocolBridge(
       container = MutableListChildren(),
       factory = DiffConsumingExampleSchemaWidgetFactory(EmptyExampleSchemaWidgetFactory()),
       eventSink = ::error,
@@ -44,13 +44,13 @@ class ProtocolDisplayTest {
       ),
     )
     val t = assertFailsWith<IllegalArgumentException> {
-      protocolDisplay.sendDiff(diff)
+      bridge.sendDiff(diff)
     }
     assertEquals("Insert attempted to replace existing widget with ID 0", t.message)
   }
 
   @Test fun duplicateIdThrows() {
-    val protocolDisplay = ProtocolDisplay(
+    val bridge = ProtocolBridge(
       container = MutableListChildren(),
       factory = DiffConsumingExampleSchemaWidgetFactory(EmptyExampleSchemaWidgetFactory()),
       eventSink = ::error,
@@ -66,9 +66,9 @@ class ProtocolDisplayTest {
         ),
       ),
     )
-    protocolDisplay.sendDiff(diff)
+    bridge.sendDiff(diff)
     val t = assertFailsWith<IllegalArgumentException> {
-      protocolDisplay.sendDiff(diff)
+      bridge.sendDiff(diff)
     }
     assertEquals("Insert attempted to replace existing widget with ID 1", t.message)
   }