Repository: cashapp/redwood
Pull request #532 (2022-11-21 18:58:25)

Move Compose state management out of the applier
And into a new(ish) type, `ProtocolBridge`, a mirror image of the `ProtocolBridge` that sits on the widget side.

Refs #14. This unblocks the ability to move the applier into the compose module, break the dependency between the compose and protocol-compose module, and run composition without the protocol!


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 e5455a745..18c6a8ba3 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
@@ -39,15 +39,14 @@ import com.squareup.kotlinpoet.asTypeName
 
 /*
 class DiffProducingSunspotWidgetFactory(
+  public override val bridge: ProtocolBridge,
   private val json: Json = Json.Default,
   private val mismatchHandler: ProtocolMismatchHandler = ProtocolMismatchHandler.Throwing,
 ) : SunspotWidgetFactory<Nothing>, DiffProducingWidget.Factory {
-  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)
+  override val RedwoodLayout = DiffProducingRedwoodLayoutWidgetFactory(delegate.RedwoodLayout, bridge, json, mismatchHandler)
+  override fun SunspotBox(): SunspotBox<Nothing> = ProtocolSunspotBox(bridge.nextId(), json, mismatchHandler)
+  override fun SunspotText(): SunspotText<Nothing> = ProtocolSunspotText(bridge.nextId(), json, mismatchHandler)
+  override fun SunspotButton(): SunspotButton<Nothing> = ProtocolSunspotButton(bridge.nextId(), json, mismatchHandler)
 }
 */
 internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = schema): FileSpec {
@@ -60,11 +59,7 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
         .addSuperinterface(ComposeProtocol.DiffProducingWidgetFactory)
         .primaryConstructor(
           FunSpec.constructorBuilder()
-            .apply {
-              if (schema !== host) {
-                addParameter("ids", ComposeProtocol.IdAllocator)
-              }
-            }
+            .addParameter("bridge", ComposeProtocol.ProtocolBridge)
             .addParameter(
               ParameterSpec.builder("json", KotlinxSerialization.Json)
                 .defaultValue("%T", KotlinxSerialization.JsonDefault)
@@ -77,6 +72,11 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
             )
             .build(),
         )
+        .addProperty(
+          PropertySpec.builder("bridge", ComposeProtocol.ProtocolBridge, PUBLIC, OVERRIDE)
+            .initializer("bridge")
+            .build(),
+        )
         .addProperty(
           PropertySpec.builder("json", KotlinxSerialization.Json, PRIVATE)
             .initializer("json")
@@ -87,24 +87,13 @@ 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(ids, json, mismatchHandler)", dependency.diffProducingWidgetFactoryType(host))
+                  .initializer("%T(bridge, json, mismatchHandler)", dependency.diffProducingWidgetFactoryType(host))
                   .build(),
               )
             }
@@ -116,7 +105,7 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
                 .addModifiers(OVERRIDE)
                 .returns(schema.widgetType(widget).parameterizedBy(NOTHING))
                 .addStatement(
-                  "return %T(ids.next(), json, mismatchHandler)",
+                  "return %T(bridge, json, mismatchHandler)",
                   schema.diffProducingWidgetType(widget, host),
                 )
                 .build(),
@@ -130,10 +119,11 @@ internal fun generateDiffProducingWidgetFactory(schema: Schema, host: Schema = s
 
 /*
 internal class DiffProducingSunspotButton(
-  public override val id: Id,
+  private val bridge: ProtocolBridge,
   private val json: Json,
   private val mismatchHandler: ProtocolMismatchHandler,
-) : AbstractDiffProducingWidget, SunspotButton<Nothing> {
+) : DiffProducingWidget, SunspotButton<Nothing> {
+  public override val id: Id = bridge.nextId()
   public override val type: Int get() = 3
 
   private var onClick: (() -> Unit)? = null
@@ -147,17 +137,17 @@ internal class DiffProducingSunspotButton(
       val json = buildJsonArray {
         value.forEach { element -> add(element.toJsonElement(json))
       }
-      appendDiff(LayoutModifiers(id, json))
+      bridge.append(LayoutModifiers(id, json))
     }
 
   override fun text(text: String?) {
-    appendDiff(PropertyDiff(this.id, 1, json.encodeToJsonElement(serializer_0, text)))
+    bridge.append(PropertyDiff(this.id, 1, json.encodeToJsonElement(serializer_0, text)))
   }
 
   override fun onClick(onClick: (() -> Unit)?) {
     val onClickSet = onClick != null
     if (onClickSet != (this.onClick != null)) {
-      appendDiff(PropertyDiff(this.id, 3, onClickSet))
+      bridge.append(PropertyDiff(this.id, 3, onClickSet))
     }
     this.onClick = onClick
   }
@@ -177,18 +167,18 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
     .addType(
       TypeSpec.classBuilder(type)
         .addModifiers(INTERNAL)
-        .superclass(ComposeProtocol.AbstractDiffProducingWidget)
+        .addSuperinterface(ComposeProtocol.DiffProducingWidget)
         .addSuperinterface(widgetName.parameterizedBy(NOTHING))
         .primaryConstructor(
           FunSpec.constructorBuilder()
-            .addParameter("id", Protocol.Id)
+            .addParameter("bridge", ComposeProtocol.ProtocolBridge)
             .addParameter("json", KotlinxSerialization.Json)
             .addParameter("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
             .build(),
         )
         .addProperty(
-          PropertySpec.builder("id", Protocol.Id, PUBLIC, OVERRIDE)
-            .initializer("id")
+          PropertySpec.builder("bridge", ComposeProtocol.ProtocolBridge, PRIVATE)
+            .initializer("bridge")
             .build(),
         )
         .addProperty(
@@ -201,6 +191,11 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
             .initializer("mismatchHandler")
             .build(),
         )
+        .addProperty(
+          PropertySpec.builder("id", Protocol.Id, PUBLIC, OVERRIDE)
+            .initializer("bridge.nextId()")
+            .build(),
+        )
         .addProperty(
           PropertySpec.builder("type", INT, PUBLIC, OVERRIDE)
             .getter(
@@ -227,7 +222,7 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                     .addModifiers(OVERRIDE)
                     .addParameter(trait.name, traitTypeName)
                     .addStatement(
-                      "appendDiff(%T(this.id, %LU, json.encodeToJsonElement(serializer_%L, %N)))",
+                      "bridge.append(%T(this.id, %LU, json.encodeToJsonElement(serializer_%L, %N)))",
                       Protocol.PropertyDiff,
                       trait.tag,
                       serializerId,
@@ -250,7 +245,7 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                     .addStatement("val %1NSet = %1N != null", trait.name)
                     .beginControlFlow("if (%1NSet != (this.%1N != null))", trait.name)
                     .addStatement(
-                      "appendDiff(%T(this.id, %LU, %M(%NSet)))",
+                      "bridge.append(%T(this.id, %LU, %M(%NSet)))",
                       Protocol.PropertyDiff,
                       trait.tag,
                       KotlinxSerialization.JsonPrimitive,
@@ -267,7 +262,7 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                     .addModifiers(OVERRIDE)
                     .getter(
                       FunSpec.getterBuilder()
-                        .addStatement("return diffProducingWidgetChildren(%LU)", trait.tag)
+                        .addStatement("return bridge.widgetChildren(id, %LU)", trait.tag)
                         .build(),
                     )
                     .build(),
@@ -327,7 +322,7 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
             .setter(
               FunSpec.setterBuilder()
                 .addParameter("value", Redwood.LayoutModifier)
-                .addStatement("appendDiff(%T(id, value.%M(json)))", Protocol.LayoutModifiers, host.toJsonArray)
+                .addStatement("bridge.append(%T(id, value.%M(json)))", Protocol.LayoutModifiers, host.toJsonArray)
                 .build(),
             )
             .build(),
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 1c6b884be..359d43ebe 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
@@ -34,11 +34,9 @@ internal object Protocol {
 }
 
 internal object ComposeProtocol {
-  val AbstractDiffProducingWidget =
-    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 ProtocolBridge = ClassName("app.cash.redwood.protocol.compose", "ProtocolBridge")
   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
deleted file mode 100644
index c13a0f972..000000000
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/AbstractDiffProducingWidget.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * 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.LayoutModifiers
-import app.cash.redwood.protocol.PropertyDiff
-import app.cash.redwood.widget.Widget.Children
-
-/**
- * @suppress For generated code usage only.
- */
-public abstract class AbstractDiffProducingWidget : DiffProducingWidget {
-  @Suppress("PropertyName") // Avoiding potential collision with subtype properties.
-  internal lateinit var _diffAppender: DiffAppender
-
-  protected fun appendDiff(layoutModifiers: LayoutModifiers) {
-    _diffAppender.append(layoutModifiers)
-  }
-
-  protected fun appendDiff(diff: PropertyDiff) {
-    _diffAppender.append(diff)
-  }
-
-  protected fun diffProducingWidgetChildren(tag: UInt): Children<Nothing> {
-    return DiffProducingWidgetChildren(id, tag, _diffAppender)
-  }
-}
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 aeb03026d..0307a131e 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
@@ -35,5 +35,7 @@ public interface DiffProducingWidget : Widget<Nothing> {
   /**
    * Marker interface for types whose functions create [DiffProducingWidget]s.
    */
-  public interface Factory : Widget.Factory<Nothing>
+  public interface Factory : Widget.Factory<Nothing> {
+    public val bridge: ProtocolBridge
+  }
 }
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 6c00572c3..41454c6c9 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
@@ -22,24 +22,28 @@ import app.cash.redwood.widget.Widget
 internal class DiffProducingWidgetChildren(
   private val id: Id,
   private val tag: UInt,
-  private val diffAppender: DiffAppender,
+  private val bridge: ProtocolBridge,
 ) : Widget.Children<Nothing> {
-  val ids = mutableListOf<Id>()
+  private val ids = mutableListOf<Id>()
 
   override fun insert(index: Int, widget: Widget<Nothing>) {
     widget as DiffProducingWidget
     ids.add(index, widget.id)
-    diffAppender.append(ChildrenDiff.Insert(id, tag, widget.id, widget.type, index))
+    bridge.addWidget(widget)
+    bridge.append(ChildrenDiff.Insert(id, tag, widget.id, widget.type, index))
   }
 
   override fun remove(index: Int, count: Int) {
+    for (i in index until index + count) {
+      bridge.removeWidget(ids[i])
+    }
     ids.remove(index, count)
-    diffAppender.append(ChildrenDiff.Remove(id, tag, index, count))
+    bridge.append(ChildrenDiff.Remove(id, tag, index, count))
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
     ids.move(fromIndex, toIndex, count)
-    diffAppender.append(ChildrenDiff.Move(id, tag, fromIndex, toIndex, count))
+    bridge.append(ChildrenDiff.Move(id, tag, fromIndex, toIndex, count))
   }
 
   override fun onLayoutModifierUpdated(index: Int) {
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
deleted file mode 100644
index bd215d1b7..000000000
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/IdAllocator.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * 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 19be40156..1ebd79e63 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
@@ -19,8 +19,6 @@ import androidx.compose.runtime.AbstractApplier
 import androidx.compose.runtime.Applier
 import app.cash.redwood.compose._ChildrenWidget
 import app.cash.redwood.compose._RedwoodApplier
-import app.cash.redwood.protocol.ChildrenDiff.Companion.RootChildrenTag
-import app.cash.redwood.protocol.Id
 import app.cash.redwood.widget.Widget
 
 /**
@@ -29,7 +27,7 @@ import app.cash.redwood.widget.Widget
  * multiple children.
  *
  * Nodes in the tree are required to alternate between [_ChildrenWidget] instances and
- * [AbstractDiffProducingWidget] subtypes starting from the root. This invariant is maintained by
+ * [DiffProducingWidget] subtypes starting from the root. This invariant is maintained by
  * virtue of the fact that all of the input `@Composables` should be generated code.
  *
  * For example, a node tree may look like this:
@@ -45,8 +43,8 @@ import app.cash.redwood.widget.Widget
  *        |              |              /         \
  *   ButtonNode     ButtonNode     TextNode     TextNode
  * ```
- * But the protocol diff output would only record [AbstractDiffProducingWidget] nodes using
- * their [AbstractDiffProducingWidget.id] and [AbstractDiffProducingWidget.type] value:
+ * But the protocol diff output would only record [DiffProducingWidget] nodes using
+ * their [DiffProducingWidget.id] and [DiffProducingWidget.type] value:
  * ```
  * Insert(id=<root-id>, tag=1, type=<toolbar-type>, childId=<toolbar-id>)
  * Insert(id=<toolbar-id>, tag=1, type=<button-type>, childId=..)
@@ -60,20 +58,18 @@ import app.cash.redwood.widget.Widget
  * The hierarchy is maintained by Compose's slot table and is represented by dotted lines.
  */
 internal class ProtocolApplier(
-  override val factory: DiffProducingWidget.Factory,
-  private val diffAppender: DiffAppender,
-) : _RedwoodApplier<DiffProducingWidget.Factory>, AbstractApplier<Widget<Nothing>>(
-  root = _ChildrenWidget(
-    DiffProducingWidgetChildren(Id.Root, RootChildrenTag, diffAppender),
-  ),
+  override val factory: Widget.Factory<Nothing>,
+  rootChildren: Widget.Children<Nothing>,
+  private val onEndChanges: () -> Unit = {},
+) : _RedwoodApplier<Widget.Factory<Nothing>>, AbstractApplier<Widget<Nothing>>(
+  root = _ChildrenWidget(rootChildren),
 ) {
-  internal val nodes = mutableMapOf(Id.Root to root)
   private var closed = false
 
   override fun onEndChanges() {
     check(!closed)
 
-    diffAppender.trySend()
+    onEndChanges.invoke()
   }
 
   override fun insertTopDown(index: Int, instance: Widget<Nothing>) {
@@ -83,37 +79,25 @@ internal class ProtocolApplier(
       instance.children = instance.accessor!!.invoke(current)
       instance.accessor = null
     } else {
-      instance as AbstractDiffProducingWidget
-      instance._diffAppender = diffAppender
-
-      nodes[instance.id] = instance
-
       val current = current as _ChildrenWidget
       current.children!!.insert(index, instance)
     }
   }
 
   override fun insertBottomUp(index: Int, instance: Widget<Nothing>) {
-    // Ignored, we insert top-down for now.
+    // Ignored, we insert top-down.
   }
 
   override fun remove(index: Int, count: Int) {
     check(!closed)
 
-    // Children instances are never removed from their parents.
     val current = current as _ChildrenWidget
-    val children = current.children as DiffProducingWidgetChildren
-
-    for (i in index until index + count) {
-      nodes.remove(children.ids[i])
-    }
-    children.remove(index, count)
+    current.children!!.remove(index, count)
   }
 
   override fun move(from: Int, to: Int, count: Int) {
     check(!closed)
 
-    // Children instances are never moved within their parents.
     val current = current as _ChildrenWidget
     current.children!!.move(from, to, count)
   }
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffAppender.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
similarity index 54%
rename from redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffAppender.kt
rename to redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
index 3687ea4d9..dcc3340af 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/DiffAppender.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2021 Square, Inc.
+ * 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.
@@ -17,39 +17,45 @@ package app.cash.redwood.protocol.compose
 
 import app.cash.redwood.protocol.ChildrenDiff
 import app.cash.redwood.protocol.Diff
-import app.cash.redwood.protocol.DiffSink
+import app.cash.redwood.protocol.Event
+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
+
+public class ProtocolBridge : EventSink {
+  private var nextValue = Id.Root.value + 1U
+  private val nodes = mutableMapOf<Id, DiffProducingWidget>()
 
-/**
- * Aggregate [ChildrenDiff]s and [PropertyDiff]s until [trySend] sends them as a [Diff] to
- * [diffSink]. Each call to [trySend] clears the internal state back to empty.
- *
- * This class is not thread safe.
- */
-internal class DiffAppender(private val diffSink: DiffSink) {
   private var childrenDiffs = mutableListOf<ChildrenDiff>()
   private var layoutModifiers = mutableListOf<LayoutModifiers>()
   private var propertyDiffs = mutableListOf<PropertyDiff>()
 
-  fun append(childrenDiff: ChildrenDiff) {
+  public fun nextId(): Id {
+    val value = nextValue
+    nextValue = value + 1U
+    return Id(value)
+  }
+
+  public fun append(childrenDiff: ChildrenDiff) {
     childrenDiffs += childrenDiff
   }
 
-  fun append(layoutModifiers: LayoutModifiers) {
+  public fun append(layoutModifiers: LayoutModifiers) {
     this.layoutModifiers += layoutModifiers
   }
 
-  fun append(propertyDiff: PropertyDiff) {
+  public fun append(propertyDiff: PropertyDiff) {
     propertyDiffs += propertyDiff
   }
 
   /**
-   * If there were any calls to [append] since the last call to this function, send them as a [Diff]
-   * to [diffSink] and reset the internal lists to be empty. This function is a no-op if there were
+   * If there were any calls to [append] since the last call to this function return them as a
+   * [Diff] and reset the internal lists to be empty. This function returns null if there were
    * no calls to [append] since the last invocation.
    */
-  fun trySend() {
+  public fun createDiffOrNull(): Diff? {
     val existingChildrenDiffs = childrenDiffs
     val existingLayoutModifierDiffs = layoutModifiers
     val existingPropertyDiffs = propertyDiffs
@@ -58,12 +64,34 @@ internal class DiffAppender(private val diffSink: DiffSink) {
       layoutModifiers = mutableListOf()
       propertyDiffs = mutableListOf()
 
-      val diff = Diff(
+      return Diff(
         childrenDiffs = existingChildrenDiffs,
         layoutModifiers = existingLayoutModifierDiffs,
         propertyDiffs = existingPropertyDiffs,
       )
-      diffSink.sendDiff(diff)
     }
+    return null
+  }
+
+  public fun addWidget(widget: DiffProducingWidget) {
+    check(nodes.put(widget.id, widget) == null) {
+      "Attempted to add widget with ID ${widget.id.value} but one already exists"
+    }
+  }
+
+  public fun removeWidget(id: Id) {
+    nodes.remove(id)
+  }
+
+  public fun widgetChildren(id: Id, tag: UInt): Widget.Children<Nothing> {
+    return DiffProducingWidgetChildren(id, tag, this)
+  }
+
+  override fun sendEvent(event: Event) {
+    val node = checkNotNull(nodes[event.id]) {
+      // TODO how to handle race where an incoming event targets this removed node?
+      "Unknown node ${event.id} for event with tag ${event.tag}"
+    }
+    node.sendEvent(event)
   }
 }
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 a6df19645..c1c29dccb 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
@@ -25,15 +25,15 @@ import androidx.compose.runtime.snapshots.ObserverHandle
 import androidx.compose.runtime.snapshots.Snapshot
 import app.cash.redwood.compose.LocalWidgetVersion
 import app.cash.redwood.compose.RedwoodComposition
+import app.cash.redwood.protocol.ChildrenDiff
 import app.cash.redwood.protocol.DiffSink
-import app.cash.redwood.protocol.Event
-import app.cash.redwood.protocol.EventSink
+import app.cash.redwood.protocol.Id
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
 
-public interface ProtocolRedwoodComposition : RedwoodComposition, EventSink {
+public interface ProtocolRedwoodComposition : RedwoodComposition {
   public fun start(diffSink: DiffSink)
 }
 
@@ -66,8 +66,11 @@ private class DiffProducingRedwoodComposition(
   override fun start(diffSink: DiffSink) {
     check(!this::applier.isInitialized) { "display already initialized" }
 
-    val diffAppender = DiffAppender(diffSink)
-    applier = ProtocolApplier(factory, diffAppender)
+    val bridge = factory.bridge
+    val root = DiffProducingWidgetChildren(Id.Root, ChildrenDiff.RootChildrenTag, bridge)
+    applier = ProtocolApplier(factory, root) {
+      bridge.createDiffOrNull()?.let(diffSink::sendDiff)
+    }
     composition = Composition(applier, recomposer)
 
     // Set up a trigger to apply changes on the next frame if a global write was observed.
@@ -90,17 +93,6 @@ private class DiffProducingRedwoodComposition(
     }
   }
 
-  override fun sendEvent(event: Event) {
-    check(this::applier.isInitialized) { "display not initialized" }
-
-    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}")
-    }
-    node.sendEvent(event)
-  }
-
   @OptIn(InternalComposeApi::class) // See internal function comment below.
   override fun setContent(content: @Composable () -> Unit) {
     check(this::applier.isInitialized) { "display not initialized" }
diff --git a/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
index f8b91a170..50b82149f 100644
--- a/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
+++ b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
@@ -36,18 +36,20 @@ public fun TreehouseUi.asZiplineTreehouseUi(
   factory: DiffProducingWidget.Factory,
   widgetVersion: UInt,
 ): ZiplineTreehouseUi {
+  val bridge = factory.bridge
   val composition = ProtocolRedwoodComposition(
     scope = scope + frameClock,
     factory = factory,
     widgetVersion = widgetVersion,
   )
-  return RedwoodZiplineTreehouseUi(composition, this)
+  return RedwoodZiplineTreehouseUi(composition, this, bridge)
 }
 
 private class RedwoodZiplineTreehouseUi(
   private val composition: ProtocolRedwoodComposition,
   private val treehouseUi: TreehouseUi,
-) : ZiplineTreehouseUi, EventSink by composition {
+  private val eventSink: EventSink,
+) : ZiplineTreehouseUi, EventSink by eventSink {
   private var diffSinkToClose: DiffSinkService? = null
 
   override fun start(
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 39e8ad5e9..a87cda0e3 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
@@ -20,6 +20,7 @@ import androidx.activity.compose.setContent
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.runtime.Composable
 import app.cash.redwood.compose.AndroidUiDispatcher
+import app.cash.redwood.protocol.compose.ProtocolBridge as ComposeProtocolBridge
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.compose.ComposeWidgetChildren
@@ -50,20 +51,21 @@ class MainActivity : AppCompatActivity() {
   private fun initComposition(content: @Composable () -> Unit): ComposeWidgetChildren {
     val composeChildren = ComposeWidgetChildren()
 
+    val composeBridge = ComposeProtocolBridge()
     val composition = ProtocolRedwoodComposition(
       scope = scope,
-      factory = DiffProducingSunspotWidgetFactory(),
+      factory = DiffProducingSunspotWidgetFactory(composeBridge),
       widgetVersion = 1U,
     )
 
     val factory = DiffConsumingSunspotWidgetFactory(AndroidSunspotWidgetFactory())
-    val bridge = ProtocolBridge(
+    val widgetBridge = ProtocolBridge(
       container = composeChildren,
       factory = factory,
-      eventSink = composition,
+      eventSink = composeBridge,
     )
 
-    composition.start(bridge)
+    composition.start(widgetBridge)
     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 2e927d0cd..3d0c76e23 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
@@ -21,6 +21,7 @@ import android.widget.FrameLayout
 import android.widget.LinearLayout.LayoutParams
 import androidx.appcompat.app.AppCompatActivity
 import app.cash.redwood.compose.AndroidUiDispatcher
+import app.cash.redwood.protocol.compose.ProtocolBridge as ComposeProtocolBridge
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.ViewGroupChildren
@@ -37,9 +38,10 @@ class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
 
+    val composeBridge = ComposeProtocolBridge()
     val composition = ProtocolRedwoodComposition(
       scope = scope,
-      factory = DiffProducingSunspotWidgetFactory(),
+      factory = DiffProducingSunspotWidgetFactory(composeBridge),
       widgetVersion = 1U,
     )
 
@@ -49,13 +51,13 @@ class MainActivity : AppCompatActivity() {
     setContentView(root)
 
     val factory = DiffConsumingSunspotWidgetFactory(AndroidSunspotWidgetFactory(this))
-    val bridge = ProtocolBridge(
+    val widgetBridge = ProtocolBridge(
       container = ViewGroupChildren(root),
       factory = factory,
-      eventSink = composition,
+      eventSink = composeBridge,
     )
 
-    composition.start(bridge)
+    composition.start(widgetBridge)
 
     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 8b6d9c975..9e4c0f987 100644
--- a/samples/counter/browser/src/main/kotlin/example/browser/main.kt
+++ b/samples/counter/browser/src/main/kotlin/example/browser/main.kt
@@ -16,6 +16,7 @@
 package example.browser
 
 import app.cash.redwood.compose.WindowAnimationFrameClock
+import app.cash.redwood.protocol.compose.ProtocolBridge as ComposeProtocolBridge
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.HTMLElementChildren
@@ -31,21 +32,23 @@ import org.w3c.dom.HTMLElement
 
 fun main() {
   @OptIn(DelicateCoroutinesApi::class)
+  val scope = GlobalScope + WindowAnimationFrameClock
+  val composeBridge = ComposeProtocolBridge()
   val composition = ProtocolRedwoodComposition(
-    scope = GlobalScope + WindowAnimationFrameClock,
-    factory = DiffProducingSunspotWidgetFactory(),
+    scope = scope,
+    factory = DiffProducingSunspotWidgetFactory(composeBridge),
     widgetVersion = 1U,
   )
 
   val content = document.getElementById("content")!! as HTMLElement
   val factory = DiffConsumingSunspotWidgetFactory(HtmlSunspotNodeFactory(document))
-  val bridge = ProtocolBridge(
+  val widgetBridge = ProtocolBridge(
     container = HTMLElementChildren(content),
     factory = factory,
-    eventSink = composition,
+    eventSink = composeBridge,
   )
 
-  composition.start(bridge)
+  composition.start(widgetBridge)
 
   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 98ef5d17a..f9c024209 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
@@ -16,6 +16,7 @@
 package example.ios
 
 import androidx.compose.runtime.BroadcastFrameClock
+import app.cash.redwood.protocol.compose.ProtocolBridge as ComposeProtocolBridge
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import app.cash.redwood.protocol.widget.ProtocolBridge
 import app.cash.redwood.widget.UIViewChildren
@@ -36,9 +37,10 @@ class CounterViewControllerDelegate(
   private val scope = MainScope() + clock
 
   init {
+    val composeBridge = ComposeProtocolBridge()
     val composition = ProtocolRedwoodComposition(
       scope = scope,
-      factory = DiffProducingSunspotWidgetFactory(),
+      factory = DiffProducingSunspotWidgetFactory(composeBridge),
       widgetVersion = 1U,
     )
 
@@ -47,13 +49,13 @@ class CounterViewControllerDelegate(
       insert = { view, index -> root.insertArrangedSubview(view, index.convert()) },
     )
     val factory = DiffConsumingSunspotWidgetFactory(IosSunspotNodeFactory)
-    val bridge = ProtocolBridge(
+    val widgetBridge = ProtocolBridge(
       container = children,
       factory = factory,
-      eventSink = composition,
+      eventSink = composeBridge,
     )
 
-    composition.start(bridge)
+    composition.start(widgetBridge)
 
     composition.setContent {
       Counter()
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 5ff82849a..4bb521719 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
@@ -15,6 +15,7 @@
  */
 package app.cash.zipline.samples.emojisearch
 
+import app.cash.redwood.protocol.compose.ProtocolBridge
 import app.cash.redwood.treehouse.ZiplineTreehouseUi
 import app.cash.redwood.treehouse.asZiplineTreehouseUi
 import app.cash.zipline.samples.emojisearch.EmojiSearchEvent.SearchTermEvent
@@ -42,7 +43,7 @@ class RealEmojiSearchPresenter(
 
   override fun launch(): ZiplineTreehouseUi {
     val events = MutableSharedFlow<EmojiSearchEvent>(extraBufferCapacity = Int.MAX_VALUE)
-    val factory = DiffProducingEmojiSearchWidgetFactory(json)
+    val factory = DiffProducingEmojiSearchWidgetFactory(ProtocolBridge(), json)
     val treehouseUi = EmojiSearchTreehouseUi(
       initialViewModel = initialViewModel,
       viewModels = produceModels(events),


Test patch:
diff --git a/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt b/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
index 1969296d6..b07dcc6d1 100644
--- a/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
+++ b/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
@@ -43,7 +43,7 @@ class DiffProducingGenerationTest {
     assertThat(fileSpec.toString()).contains(
       """
       |  public override fun id(id: String): Unit {
-      |    appendDiff(PropertyDiff(this.id, 2U, json.encodeToJsonElement(serializer_0, id)))
+      |    bridge.append(PropertyDiff(this.id, 2U, json.encodeToJsonElement(serializer_0, id)))
       |  }
       |
       """.trimMargin(),
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 0b7abf6dc..a5f202d01 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
@@ -41,23 +41,18 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val factory = DiffProducingExampleSchemaWidgetFactory(json)
+    val bridge = ProtocolBridge()
+    val factory = DiffProducingExampleSchemaWidgetFactory(bridge, json)
     val textInput = factory.TextInput()
 
-    val diffProducingWidget = textInput as AbstractDiffProducingWidget
-    val diffSink = RecordingDiffSink()
-    val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget._diffAppender = diffAppender
-
     textInput.customType(10.seconds)
-    diffAppender.trySend()
 
     val expected = Diff(
       propertyDiffs = listOf(
         PropertyDiff(Id(1U), 2U, JsonPrimitive("PT10S")),
       ),
     )
-    assertEquals(expected, diffSink.diffs.single())
+    assertEquals(expected, bridge.createDiffOrNull())
   }
 
   @Test fun layoutModifierUsesSerializersModule() {
@@ -66,18 +61,13 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val factory = DiffProducingExampleSchemaWidgetFactory(json)
+    val bridge = ProtocolBridge()
+    val factory = DiffProducingExampleSchemaWidgetFactory(bridge, json)
     val button = factory.Button()
 
-    val diffProducingWidget = button as AbstractDiffProducingWidget
-    val diffSink = RecordingDiffSink()
-    val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget._diffAppender = diffAppender
-
     button.layoutModifiers = with(object : TestScope {}) {
       LayoutModifier.customType(10.seconds)
     }
-    diffAppender.trySend()
 
     val expected = Diff(
       layoutModifiers = listOf(
@@ -98,7 +88,7 @@ class DiffProducingWidgetFactoryTest {
         ),
       ),
     )
-    assertEquals(expected, diffSink.diffs.single())
+    assertEquals(expected, bridge.createDiffOrNull())
   }
 
   @Test fun layoutModifierDefaultValueNotSerialized() {
@@ -107,18 +97,13 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val factory = DiffProducingExampleSchemaWidgetFactory(json)
+    val bridge = ProtocolBridge()
+    val factory = DiffProducingExampleSchemaWidgetFactory(bridge, json)
     val button = factory.Button()
 
-    val diffProducingWidget = button as AbstractDiffProducingWidget
-    val diffSink = RecordingDiffSink()
-    val diffAppender = DiffAppender(diffSink)
-    diffProducingWidget._diffAppender = diffAppender
-
     button.layoutModifiers = with(object : TestScope {}) {
       LayoutModifier.customTypeWithDefault(10.seconds, "sup")
     }
-    diffAppender.trySend()
 
     val expected = Diff(
       layoutModifiers = listOf(
@@ -139,7 +124,7 @@ class DiffProducingWidgetFactoryTest {
         ),
       ),
     )
-    assertEquals(expected, diffSink.diffs.single())
+    assertEquals(expected, bridge.createDiffOrNull())
   }
 
   @Test fun eventUsesSerializersModule() {
@@ -148,11 +133,10 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val factory = DiffProducingExampleSchemaWidgetFactory(json)
+    val factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge(), json)
     val textInput = factory.TextInput()
 
-    val diffProducingWidget = textInput as AbstractDiffProducingWidget
-    diffProducingWidget._diffAppender = DiffAppender(RecordingDiffSink())
+    val diffProducingWidget = textInput as DiffProducingWidget
 
     var argument: Duration? = null
     textInput.onChangeCustomType {
@@ -165,8 +149,8 @@ class DiffProducingWidgetFactoryTest {
   }
 
   @Test fun unknownEventThrowsDefault() {
-    val factory = DiffProducingExampleSchemaWidgetFactory()
-    val button = factory.Button() as AbstractDiffProducingWidget
+    val factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge())
+    val button = factory.Button() as DiffProducingWidget
 
     val event = Event(Id(1U), 3456543U)
     val t = assertFailsWith<IllegalArgumentException> {
@@ -178,8 +162,8 @@ class DiffProducingWidgetFactoryTest {
 
   @Test fun unknownEventCallsHandler() {
     val handler = RecordingProtocolMismatchHandler()
-    val factory = DiffProducingExampleSchemaWidgetFactory(mismatchHandler = handler)
-    val button = factory.Button() as AbstractDiffProducingWidget
+    val factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge(), mismatchHandler = handler)
+    val button = factory.Button() as DiffProducingWidget
 
     button.sendEvent(Event(Id(1U), 3456543U))
 
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
index 5dba15ec6..8df75b511 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
@@ -47,7 +47,7 @@ class ProtocolTest {
     val clock = BroadcastFrameClock()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      factory = DiffProducingExampleSchemaWidgetFactory(),
+      factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge()),
       widgetVersion = 22U,
     )
     composition.start { }
@@ -65,7 +65,7 @@ class ProtocolTest {
     val clock = BroadcastFrameClock()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      factory = DiffProducingExampleSchemaWidgetFactory(),
+      factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge()),
       widgetVersion = 1U,
     )
     val diffs = ArrayDeque<Diff>()
@@ -109,9 +109,10 @@ class ProtocolTest {
   @Test fun protocolSkipsLambdaChangeOfSamePresence() = runTest {
     val clock = BroadcastFrameClock()
     var state by mutableStateOf(0)
+    val bridge = ProtocolBridge()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      factory = DiffProducingExampleSchemaWidgetFactory(),
+      factory = DiffProducingExampleSchemaWidgetFactory(bridge),
       widgetVersion = 1U,
     )
     val diffs = ArrayDeque<Diff>()
@@ -148,7 +149,7 @@ class ProtocolTest {
     )
 
     // Invoke the onClick lambda to move the state from 0 to 1.
-    composition.sendEvent(Event(Id(1U), 2U))
+    bridge.sendEvent(Event(Id(1U), 2U))
     yield() // Allow state change to be handled.
 
     clock.awaitFrame()
@@ -162,7 +163,7 @@ class ProtocolTest {
     )
 
     // Invoke the onClick lambda to move the state from 1 to 2.
-    composition.sendEvent(Event(Id(1U), 2U))
+    bridge.sendEvent(Event(Id(1U), 2U))
     yield() // Allow state change to be handled.
 
     clock.awaitFrame()
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingDiffSink.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingDiffSink.kt
deleted file mode 100644
index 32179db48..000000000
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingDiffSink.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * 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.Diff
-import app.cash.redwood.protocol.DiffSink
-
-class RecordingDiffSink : DiffSink {
-  val diffs = mutableListOf<Diff>()
-
-  override fun sendDiff(diff: Diff) {
-    diffs += diff
-  }
-}