Repository: cashapp/redwood
Pull request #644 (2022-12-16 19:57:05)

Expose only a minimal bridge type for diff-producing codegen
This reduces the generated API surface to something that hides 100% of the factory and widget types. Instead, a `ProtocolBridge` subtype is exposed with its companion acting as a factory.

JakeWharton: This side is mostly boring but was easier. There's a symmetric change to the consuming side which is much more interesting as it should allow divorcing the creation of the diff-consuming wrapper (one per code load) from creation of the native widget factory (effectively a singleton) meaning we can eliminate the widget system concept / awkwardness.

Patch:
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 1fc650428..3fdc828a5 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
@@ -23,6 +23,8 @@ import app.cash.redwood.widget.Widget
 /**
  * A [Widget] with no platform-specific representation which instead produces protocol diffs
  * based on its properties.
+ *
+ * @suppress For generated code use only.
  */
 public interface DiffProducingWidget : Widget<Nothing> {
   public val id: Id
@@ -32,9 +34,4 @@ public interface DiffProducingWidget : Widget<Nothing> {
     get() = throw AssertionError()
 
   public fun sendEvent(event: Event)
-
-  /** A [Widget.Provider] whose [Widget]s write to a [ProtocolBridge]. */
-  public interface Provider : Widget.Provider<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 b6de731ac..0840b7f06 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
@@ -23,28 +23,28 @@ import app.cash.redwood.widget.Widget
 internal class DiffProducingWidgetChildren(
   private val id: Id,
   private val tag: ChildrenTag,
-  private val bridge: ProtocolBridge,
+  private val state: ProtocolState,
 ) : Widget.Children<Nothing> {
   private val ids = mutableListOf<Id>()
 
   override fun insert(index: Int, widget: Widget<Nothing>) {
     widget as DiffProducingWidget
     ids.add(index, widget.id)
-    bridge.addWidget(widget)
-    bridge.append(ChildrenDiff.Insert(id, tag, widget.id, widget.tag, index))
+    state.addWidget(widget)
+    state.append(ChildrenDiff.Insert(id, tag, widget.id, widget.tag, index))
   }
 
   override fun remove(index: Int, count: Int) {
     for (i in index until index + count) {
-      bridge.removeWidget(ids[i])
+      state.removeWidget(ids[i])
     }
     ids.remove(index, count)
-    bridge.append(ChildrenDiff.Remove(id, tag, index, count))
+    state.append(ChildrenDiff.Remove(id, tag, index, count))
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
     ids.move(fromIndex, toIndex, count)
-    bridge.append(ChildrenDiff.Move(id, tag, fromIndex, toIndex, count))
+    state.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/ProtocolBridge.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
index 37b1a1ad6..9a09c5253 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolBridge.kt
@@ -15,84 +15,44 @@
  */
 package app.cash.redwood.protocol.compose
 
-import app.cash.redwood.protocol.ChildrenDiff
 import app.cash.redwood.protocol.ChildrenTag
 import app.cash.redwood.protocol.Diff
 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
+import kotlinx.serialization.json.Json
 
-public class ProtocolBridge : EventSink {
-  private var nextValue = Id.Root.value + 1L
-  private val nodes = mutableMapOf<Id, DiffProducingWidget>()
-
-  private var childrenDiffs = mutableListOf<ChildrenDiff>()
-  private var layoutModifiers = mutableListOf<LayoutModifiers>()
-  private var propertyDiffs = mutableListOf<PropertyDiff>()
-
-  public fun nextId(): Id {
-    val value = nextValue
-    nextValue = value + 1L
-    return Id(value)
-  }
-
-  public fun append(childrenDiff: ChildrenDiff) {
-    childrenDiffs += childrenDiff
-  }
-
-  public fun append(layoutModifiers: LayoutModifiers) {
-    this.layoutModifiers += layoutModifiers
-  }
-
-  public fun append(propertyDiff: PropertyDiff) {
-    propertyDiffs += propertyDiff
-  }
-
+/**
+ * Exposes a [Widget.Children] and [Widget.Provider] whose changes can be captured as a [Diff] to
+ * send to a remote frontend. Incoming [Event]s can also be sent to this instance and will be routed
+ * to the appropriate handler.
+ */
+public interface ProtocolBridge : EventSink {
   /**
-   * 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.
+   * The root of the widget tree onto which [provider]-produced widgets can be added. Changes to
+   * this set of children records changes into the diff as [Id.Root] and [ChildrenTag.Root].
    */
-  public fun createDiffOrNull(): Diff? {
-    val existingChildrenDiffs = childrenDiffs
-    val existingLayoutModifierDiffs = layoutModifiers
-    val existingPropertyDiffs = propertyDiffs
-    if (existingPropertyDiffs.isNotEmpty() || existingLayoutModifierDiffs.isNotEmpty() || existingChildrenDiffs.isNotEmpty()) {
-      childrenDiffs = mutableListOf()
-      layoutModifiers = mutableListOf()
-      propertyDiffs = mutableListOf()
+  public val root: Widget.Children<Nothing>
 
-      return Diff(
-        childrenDiffs = existingChildrenDiffs,
-        layoutModifiers = existingLayoutModifierDiffs,
-        propertyDiffs = existingPropertyDiffs,
-      )
-    }
-    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: ChildrenTag): Widget.Children<Nothing> {
-    return DiffProducingWidgetChildren(id, tag, this)
-  }
+  /**
+   * The provider of factories of widgets which record property changes into the diff and whose
+   * children changes are also recorded into the diff. You **must** attach returned widgets to
+   * [root] or the children of a widget in the tree beneath [root] in order for it to be tracked.
+   */
+  public val provider: Widget.Provider<Nothing>
 
-  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)
+  /**
+   * Returns any changes to [root] or [provider]-produced widgets since the last time this function
+   * was called. Returns null if no changes occurred.
+   */
+  public fun createDiffOrNull(): Diff?
+
+  public interface Factory {
+    /** Create a new [ProtocolBridge] with its own protocol state and set of tracked widgets. */
+    public fun create(
+      json: Json = Json.Default,
+      mismatchHandler: ProtocolMismatchHandler = ProtocolMismatchHandler.Throwing,
+    ): ProtocolBridge
   }
 }
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 f752e0c00..2cc8cf654 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
@@ -22,9 +22,7 @@ import androidx.compose.runtime.currentComposer
 import app.cash.redwood.compose.LocalWidgetVersion
 import app.cash.redwood.compose.RedwoodComposition
 import app.cash.redwood.compose.WidgetApplier
-import app.cash.redwood.protocol.ChildrenTag
 import app.cash.redwood.protocol.DiffSink
-import app.cash.redwood.protocol.Id
 import kotlinx.coroutines.CoroutineScope
 
 /**
@@ -33,13 +31,11 @@ import kotlinx.coroutines.CoroutineScope
  */
 public fun ProtocolRedwoodComposition(
   scope: CoroutineScope,
-  provider: DiffProducingWidget.Provider,
+  bridge: ProtocolBridge,
   diffSink: DiffSink,
   widgetVersion: UInt,
 ): RedwoodComposition {
-  val bridge = provider.bridge
-  val root = DiffProducingWidgetChildren(Id.Root, ChildrenTag.Root, bridge)
-  val applier = WidgetApplier(provider, root) {
+  val applier = WidgetApplier(bridge.provider, bridge.root) {
     bridge.createDiffOrNull()?.let(diffSink::sendDiff)
   }
   val composition = RedwoodComposition(scope, applier)
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolState.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolState.kt
new file mode 100644
index 000000000..88d0316d7
--- /dev/null
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolState.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.ChildrenDiff
+import app.cash.redwood.protocol.ChildrenTag
+import app.cash.redwood.protocol.Diff
+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
+
+/** @suppress For generated code use only. */
+public class ProtocolState {
+  private var nextValue = Id.Root.value + 1L
+  private val nodes = mutableMapOf<Id, DiffProducingWidget>()
+
+  private var childrenDiffs = mutableListOf<ChildrenDiff>()
+  private var layoutModifiers = mutableListOf<LayoutModifiers>()
+  private var propertyDiffs = mutableListOf<PropertyDiff>()
+
+  public fun nextId(): Id {
+    val value = nextValue
+    nextValue = value + 1L
+    return Id(value)
+  }
+
+  public fun append(childrenDiff: ChildrenDiff) {
+    childrenDiffs += childrenDiff
+  }
+
+  public fun append(layoutModifiers: LayoutModifiers) {
+    this.layoutModifiers += layoutModifiers
+  }
+
+  public fun append(propertyDiff: PropertyDiff) {
+    propertyDiffs += propertyDiff
+  }
+
+  /**
+   * 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.
+   */
+  public fun createDiffOrNull(): Diff? {
+    val existingChildrenDiffs = childrenDiffs
+    val existingLayoutModifierDiffs = layoutModifiers
+    val existingPropertyDiffs = propertyDiffs
+    if (existingPropertyDiffs.isNotEmpty() || existingLayoutModifierDiffs.isNotEmpty() || existingChildrenDiffs.isNotEmpty()) {
+      childrenDiffs = mutableListOf()
+      layoutModifiers = mutableListOf()
+      propertyDiffs = mutableListOf()
+
+      return Diff(
+        childrenDiffs = existingChildrenDiffs,
+        layoutModifiers = existingLayoutModifierDiffs,
+        propertyDiffs = existingPropertyDiffs,
+      )
+    }
+    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: ChildrenTag): Widget.Children<Nothing> {
+    return DiffProducingWidgetChildren(id, tag, this)
+  }
+
+  public 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-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffProducingGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffProducingGeneration.kt
index 65903e0a9..51b31f37e 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffProducingGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffProducingGeneration.kt
@@ -35,55 +35,105 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.UNIT
 import com.squareup.kotlinpoet.asTypeName
+import com.squareup.kotlinpoet.joinToCode
 
 /*
-class DiffProducingSunspotWidgetFactories(
-  bridge: ProtocolBridge,
-  json: Json = Json.Default,
-  mismatchHandler: ProtocolMismatchHandler = ProtocolMismatchHandler.Throwing,
-) : SunspotWidgetFactoryProvider<Nothing>, DiffProducingWidget.Provider {
-  override val Sunspot: SunspotWidgetFactory<Nothing> = DiffProducingSunspotWidgetFactory(bridge, json, mismatchHandler)
-  override val RedwoodLayout: RedwoodLayoutWidgetFactory<Nothing> = DiffProducingRedwoodLayoutWidgetFactory(bridge, json, mismatchHandler)
+class SunspotProtocolBridge(
+  private val bridge: ProtocolBridge,
+  override val root: Widget.Children<Nothing>,
+  override val provider: SunspotWidgetFactoryProvider<Nothing>,
+) : ProtocolBridge {
+  override fun createDiffOrNull(): Diff? = bridge.createDiffOrNull()
+  override fun sendEvent(event: Event) = bridge.sendEvent(event)
+
+  companion object : ProtocolBridge.Factory {
+    override fun create(
+      json: Json,
+      mismatchHandler: ProtocolMismatchHandler,
+    ): SunspotProtocolBridge {
+      val bridge = ProtocolBridge()
+      val root = bridge.widgetChildren(Id.Root, ChildrenTag.Root)
+      val factories = SunspotWidgetFactories(
+          Sunspot = DiffProducingSunspotWidgetFactory(bridge, json, mismatchHandler),
+          RedwoodLayout = DiffProducingRedwoodLayoutWidgetFactory(bridge, json, mismatchHandler),
+      )
+      return SunspotProtocolBridge(bridge, root, factories)
+    }
+  }
 }
 */
-internal fun generateDiffProducingWidgetFactories(schema: ProtocolSchema): FileSpec {
-  val type = schema.diffProducingWidgetFactoriesType()
+internal fun generateProtocolBridge(
+  schema: ProtocolSchema,
+): FileSpec {
+  val type = schema.protocolBridgeType()
+  val providerType = schema.getWidgetFactoryProviderType().parameterizedBy(NOTHING)
   return FileSpec.builder(type.packageName, type.simpleName)
     .addType(
       TypeSpec.classBuilder(type)
+        .addSuperinterface(ComposeProtocol.ProtocolBridge)
         .primaryConstructor(
           FunSpec.constructorBuilder()
-            .addParameter("bridge", ComposeProtocol.ProtocolBridge)
-            .addParameter(
-              ParameterSpec.builder("json", KotlinxSerialization.Json)
-                .defaultValue("%T", KotlinxSerialization.JsonDefault)
-                .build(),
-            )
-            .addParameter(
-              ParameterSpec.builder("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
-                .defaultValue("%T.Throwing", ComposeProtocol.ProtocolMismatchHandler)
-                .build(),
-            )
+            .addParameter("bridge", ComposeProtocol.ProtocolState)
+            .addParameter("root", RedwoodWidget.WidgetChildren.parameterizedBy(NOTHING))
+            .addParameter("provider", providerType)
             .build(),
         )
-        .addSuperinterface(schema.getWidgetFactoryProviderType().parameterizedBy(NOTHING))
-        .addSuperinterface(ComposeProtocol.DiffProducingWidgetProvider)
         .addProperty(
-          PropertySpec.builder("bridge", ComposeProtocol.ProtocolBridge, OVERRIDE)
+          PropertySpec.builder("bridge", ComposeProtocol.ProtocolState, PRIVATE)
             .initializer("bridge")
             .build(),
         )
-        .apply {
-          for (dependency in schema.allSchemas) {
-            addProperty(
-              PropertySpec.builder(dependency.name, dependency.getWidgetFactoryType().parameterizedBy(NOTHING))
+        .addProperty(
+          PropertySpec.builder("root", RedwoodWidget.WidgetChildren.parameterizedBy(NOTHING), OVERRIDE)
+            .initializer("root")
+            .build(),
+        )
+        .addProperty(
+          PropertySpec.builder("provider", providerType, OVERRIDE)
+            .initializer("provider")
+            .build(),
+        )
+        .addFunction(
+          FunSpec.builder("createDiffOrNull")
+            .addModifiers(OVERRIDE)
+            .returns(Protocol.Diff.copy(nullable = true))
+            .addStatement("return bridge.createDiffOrNull()")
+            .build(),
+        )
+        .addFunction(
+          FunSpec.builder("sendEvent")
+            .addModifiers(OVERRIDE)
+            .returns(UNIT)
+            .addParameter("event", Protocol.Event)
+            .addStatement("return bridge.sendEvent(event)")
+            .build(),
+        )
+        .addType(
+          TypeSpec.companionObjectBuilder()
+            .addSuperinterface(ComposeProtocol.ProtocolBridgeFactory)
+            .addFunction(
+              FunSpec.builder("create")
                 .addModifiers(OVERRIDE)
-                .initializer("%T(bridge, json, mismatchHandler)", dependency.diffProducingWidgetFactoryType(schema))
+                .addParameter("json", KotlinxSerialization.Json)
+                .addParameter("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
+                .returns(type)
+                .addStatement("val bridge = %T()", ComposeProtocol.ProtocolState)
+                .addStatement("val root = bridge.widgetChildren(%T.Root, %T.Root)", Protocol.Id, Protocol.ChildrenTag)
+                .apply {
+                  val arguments = buildList<CodeBlock> {
+                    for (dependency in schema.allSchemas) {
+                      add(CodeBlock.of("%N = %T(bridge, json, mismatchHandler)", dependency.name, dependency.diffProducingWidgetFactoryType(schema)))
+                    }
+                  }
+                  addStatement("val factories = %T(\n%L)", schema.getWidgetFactoriesType(), arguments.joinToCode(separator = ",\n"))
+                }
+                .addStatement("return %T(bridge, root, factories)", type)
                 .build(),
             )
-          }
-        }
+            .build(),
+        )
         .build(),
     )
     .build()
@@ -112,13 +162,13 @@ internal fun generateDiffProducingWidgetFactory(
         .addSuperinterface(schema.getWidgetFactoryType().parameterizedBy(NOTHING))
         .primaryConstructor(
           FunSpec.constructorBuilder()
-            .addParameter("bridge", ComposeProtocol.ProtocolBridge)
+            .addParameter("bridge", ComposeProtocol.ProtocolState)
             .addParameter("json", KotlinxSerialization.Json)
             .addParameter("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
             .build(),
         )
         .addProperty(
-          PropertySpec.builder("bridge", ComposeProtocol.ProtocolBridge, PRIVATE)
+          PropertySpec.builder("bridge", ComposeProtocol.ProtocolState, PRIVATE)
             .initializer("bridge")
             .build(),
         )
@@ -209,13 +259,13 @@ internal fun generateDiffProducingWidget(
         .addSuperinterface(widgetName.parameterizedBy(NOTHING))
         .primaryConstructor(
           FunSpec.constructorBuilder()
-            .addParameter("bridge", ComposeProtocol.ProtocolBridge)
+            .addParameter("bridge", ComposeProtocol.ProtocolState)
             .addParameter("json", KotlinxSerialization.Json)
             .addParameter("mismatchHandler", ComposeProtocol.ProtocolMismatchHandler)
             .build(),
         )
         .addProperty(
-          PropertySpec.builder("bridge", ComposeProtocol.ProtocolBridge, PRIVATE)
+          PropertySpec.builder("bridge", ComposeProtocol.ProtocolState, PRIVATE)
             .initializer("bridge")
             .build(),
         )
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
index 5aec1fdd2..a2fd2289b 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolCodegen.kt
@@ -28,7 +28,7 @@ public enum class ProtocolCodegenType {
 public fun ProtocolSchema.generate(type: ProtocolCodegenType, destination: Path) {
   when (type) {
     Compose -> {
-      generateDiffProducingWidgetFactories(this).writeTo(destination)
+      generateProtocolBridge(this).writeTo(destination)
       for (dependency in allSchemas) {
         generateDiffProducingWidgetFactory(dependency, host = this).writeTo(destination)
         generateDiffProducingLayoutModifiers(dependency, host = this).writeTo(destination)
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
index b90a675fd..c8de97da7 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
@@ -64,12 +64,12 @@ internal fun Schema.composePackage(host: Schema = this): String {
   }
 }
 
-internal fun Schema.diffProducingWidgetFactoriesType(): ClassName {
-  return ClassName(composePackage(), "${name}DiffProducingWidgetFactories")
+internal fun Schema.protocolBridgeType(): ClassName {
+  return ClassName(composePackage(), "${name}ProtocolBridge")
 }
 
 internal fun Schema.diffProducingWidgetFactoryType(host: Schema): ClassName {
-  return ClassName(composePackage(host), "${name}DiffProducingWidgetFactory")
+  return ClassName(composePackage(host), "DiffProducing${name}WidgetFactory")
 }
 
 internal fun Schema.diffProducingWidgetType(widget: Widget, host: Schema): ClassName {
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
index 7b2be2b26..a556eb2bc 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
@@ -27,6 +27,7 @@ import com.squareup.kotlinpoet.UNIT
 
 internal object Protocol {
   val ChildrenTag = ClassName("app.cash.redwood.protocol", "ChildrenTag")
+  val Diff = ClassName("app.cash.redwood.protocol", "Diff")
   val Event = ClassName("app.cash.redwood.protocol", "Event")
   val EventTag = ClassName("app.cash.redwood.protocol", "EventTag")
   val EventSink = ClassName("app.cash.redwood.protocol", "EventSink")
@@ -41,8 +42,9 @@ internal object Protocol {
 
 internal object ComposeProtocol {
   val DiffProducingWidget = ClassName("app.cash.redwood.protocol.compose", "DiffProducingWidget")
-  val DiffProducingWidgetProvider = DiffProducingWidget.nestedClass("Provider")
   val ProtocolBridge = ClassName("app.cash.redwood.protocol.compose", "ProtocolBridge")
+  val ProtocolBridgeFactory = ProtocolBridge.nestedClass("Factory")
+  val ProtocolState = ClassName("app.cash.redwood.protocol.compose", "ProtocolState")
   val ProtocolMismatchHandler =
     ClassName("app.cash.redwood.protocol.compose", "ProtocolMismatchHandler")
 }
diff --git a/redwood-treehouse-lazylayout-compose/src/jsMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/TreehouseLazyColumn.kt b/redwood-treehouse-lazylayout-compose/src/jsMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/TreehouseLazyColumn.kt
index 775bfd8ad..ad8b6772e 100644
--- a/redwood-treehouse-lazylayout-compose/src/jsMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/TreehouseLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-compose/src/jsMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/TreehouseLazyColumn.kt
@@ -17,14 +17,14 @@ package app.cash.redwood.treehouse.lazylayout.compose
 
 import androidx.compose.runtime.Composable
 import app.cash.redwood.LayoutScopeMarker
-import app.cash.redwood.protocol.compose.DiffProducingWidget
+import app.cash.redwood.protocol.compose.ProtocolBridge
 import app.cash.redwood.treehouse.TreehouseUi
 import app.cash.redwood.treehouse.ZiplineTreehouseUi
 import app.cash.redwood.treehouse.asZiplineTreehouseUi
 import app.cash.redwood.treehouse.lazylayout.api.LazyListIntervalContent
 
 @Composable
-public fun DiffProducingWidget.Provider.LazyColumn(content: LazyListScope.() -> Unit) {
+public fun ProtocolBridge.LazyColumn(content: LazyListScope.() -> Unit) {
   val scope = TreehouseLazyListScope(this)
   content(scope)
   LazyColumn(scope.intervals)
@@ -47,11 +47,11 @@ public inline fun <T> LazyListScope.items(
   }
 }
 
-private class TreehouseLazyListScope(private val provider: DiffProducingWidget.Provider) : LazyListScope {
+private class TreehouseLazyListScope(private val provider: ProtocolBridge) : LazyListScope {
   val intervals = mutableListOf<LazyListIntervalContent>()
 
   private class Item(
-    private val provider: DiffProducingWidget.Provider,
+    private val provider: ProtocolBridge,
     private val content: @Composable (index: Int) -> Unit,
   ) : LazyListIntervalContent.Item {
 
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 5ba01e4cc..b7e96c800 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
@@ -20,7 +20,7 @@ import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import app.cash.redwood.compose.RedwoodComposition
 import app.cash.redwood.protocol.EventSink
-import app.cash.redwood.protocol.compose.DiffProducingWidget
+import app.cash.redwood.protocol.compose.ProtocolBridge
 import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.DelicateCoroutinesApi
@@ -31,17 +31,17 @@ import kotlinx.coroutines.plus
  * The Kotlin/JS side of a treehouse UI.
  */
 public fun TreehouseUi.asZiplineTreehouseUi(
-  provider: DiffProducingWidget.Provider,
+  bridge: ProtocolBridge,
   widgetVersion: UInt,
 ): ZiplineTreehouseUi {
-  return RedwoodZiplineTreehouseUi(provider, widgetVersion, this)
+  return RedwoodZiplineTreehouseUi(bridge, widgetVersion, this)
 }
 
 private class RedwoodZiplineTreehouseUi(
-  private val provider: DiffProducingWidget.Provider,
+  private val bridge: ProtocolBridge,
   private val widgetVersion: UInt,
   private val treehouseUi: TreehouseUi,
-) : ZiplineTreehouseUi, EventSink by provider.bridge {
+) : ZiplineTreehouseUi, EventSink by bridge {
   private lateinit var diffSinkToClose: DiffSinkService
   private lateinit var composition: RedwoodComposition
 
@@ -54,7 +54,7 @@ private class RedwoodZiplineTreehouseUi(
 
     val composition = ProtocolRedwoodComposition(
       scope = scope + StandardFrameClock,
-      provider = provider,
+      bridge = bridge,
       widgetVersion = widgetVersion,
       diffSink = diffSink,
     )
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 8f0b7e63c..d988bae2c 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
@@ -24,12 +24,11 @@ import app.cash.redwood.layout.api.CrossAxisAlignment
 import app.cash.redwood.layout.api.Padding
 import app.cash.redwood.layout.compose.Column
 import app.cash.redwood.layout.compose.Row
-import app.cash.redwood.protocol.compose.DiffProducingWidget
+import app.cash.redwood.protocol.compose.ProtocolBridge
 import app.cash.redwood.treehouse.TreehouseUi
 import app.cash.redwood.treehouse.lazylayout.compose.LazyColumn
 import app.cash.redwood.treehouse.lazylayout.compose.items
 import app.cash.zipline.samples.emojisearch.EmojiSearchEvent.SearchTermEvent
-import example.schema.compose.EmojiSearchDiffProducingWidgetFactories
 import example.schema.compose.Image
 import example.schema.compose.Text
 import example.schema.compose.TextInput
@@ -39,8 +38,8 @@ class EmojiSearchTreehouseUi(
   private val initialViewModel: EmojiSearchViewModel,
   private val viewModels: Flow<EmojiSearchViewModel>,
   private val onEvent: (EmojiSearchEvent) -> Unit,
-  private val factories: EmojiSearchDiffProducingWidgetFactories,
-) : TreehouseUi, DiffProducingWidget.Provider by factories {
+  private val bridge: ProtocolBridge,
+) : TreehouseUi, ProtocolBridge by bridge {
 
   @Composable
   override fun Show() {
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 481952fe2..1bebb6bd3 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,11 +15,10 @@
  */
 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
-import example.schema.compose.EmojiSearchDiffProducingWidgetFactories
+import example.schema.compose.EmojiSearchProtocolBridge
 import example.values.TextFieldState
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,15 +42,15 @@ class RealEmojiSearchPresenter(
 
   override fun launch(): ZiplineTreehouseUi {
     val events = MutableSharedFlow<EmojiSearchEvent>(extraBufferCapacity = Int.MAX_VALUE)
-    val factories = EmojiSearchDiffProducingWidgetFactories(ProtocolBridge(), json)
+    val bridge = EmojiSearchProtocolBridge.create(json)
     val treehouseUi = EmojiSearchTreehouseUi(
       initialViewModel = initialViewModel,
       viewModels = produceModels(events),
       onEvent = events::tryEmit,
-      factories = factories,
+      bridge = bridge,
     )
     return treehouseUi.asZiplineTreehouseUi(
-      provider = factories,
+      bridge = bridge,
       widgetVersion = 0U,
     )
   }


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 595cf0717..148e2eb8a 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
@@ -25,7 +25,7 @@ import app.cash.redwood.protocol.LayoutModifierTag
 import app.cash.redwood.protocol.LayoutModifiers
 import app.cash.redwood.protocol.PropertyDiff
 import app.cash.redwood.protocol.PropertyTag
-import example.redwood.compose.ExampleSchemaDiffProducingWidgetFactories
+import example.redwood.compose.ExampleSchemaProtocolBridge
 import example.redwood.compose.TestScope
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -44,9 +44,8 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val bridge = ProtocolBridge()
-    val factory = ExampleSchemaDiffProducingWidgetFactories(bridge, json)
-    val textInput = factory.ExampleSchema.TextInput()
+    val bridge = ExampleSchemaProtocolBridge.create(json)
+    val textInput = bridge.provider.ExampleSchema.TextInput()
 
     textInput.customType(10.seconds)
 
@@ -64,9 +63,8 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val bridge = ProtocolBridge()
-    val factory = ExampleSchemaDiffProducingWidgetFactories(bridge, json)
-    val button = factory.ExampleSchema.Button()
+    val bridge = ExampleSchemaProtocolBridge.create(json)
+    val button = bridge.provider.ExampleSchema.Button()
 
     button.layoutModifiers = with(object : TestScope {}) {
       LayoutModifier.customType(10.seconds)
@@ -96,9 +94,8 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val bridge = ProtocolBridge()
-    val factory = ExampleSchemaDiffProducingWidgetFactories(bridge, json)
-    val button = factory.ExampleSchema.Button()
+    val bridge = ExampleSchemaProtocolBridge.create(json)
+    val button = bridge.provider.ExampleSchema.Button()
 
     button.layoutModifiers = with(object : TestScope {}) {
       LayoutModifier.customTypeWithDefault(10.seconds, "sup")
@@ -128,8 +125,8 @@ class DiffProducingWidgetFactoryTest {
         contextual(Duration::class, DurationIsoSerializer)
       }
     }
-    val factory = ExampleSchemaDiffProducingWidgetFactories(ProtocolBridge(), json)
-    val textInput = factory.ExampleSchema.TextInput()
+    val bridge = ExampleSchemaProtocolBridge.create(json)
+    val textInput = bridge.provider.ExampleSchema.TextInput()
 
     val diffProducingWidget = textInput as DiffProducingWidget
 
@@ -144,8 +141,8 @@ class DiffProducingWidgetFactoryTest {
   }
 
   @Test fun unknownEventThrowsDefault() {
-    val factory = ExampleSchemaDiffProducingWidgetFactories(ProtocolBridge())
-    val button = factory.ExampleSchema.Button() as DiffProducingWidget
+    val bridge = ExampleSchemaProtocolBridge.create()
+    val button = bridge.provider.ExampleSchema.Button() as DiffProducingWidget
 
     val event = Event(Id(1), EventTag(3456543))
     val t = assertFailsWith<IllegalArgumentException> {
@@ -157,8 +154,8 @@ class DiffProducingWidgetFactoryTest {
 
   @Test fun unknownEventCallsHandler() {
     val handler = RecordingProtocolMismatchHandler()
-    val factory = ExampleSchemaDiffProducingWidgetFactories(ProtocolBridge(), mismatchHandler = handler)
-    val button = factory.ExampleSchema.Button() as DiffProducingWidget
+    val bridge = ExampleSchemaProtocolBridge.create(mismatchHandler = handler)
+    val button = bridge.provider.ExampleSchema.Button() as DiffProducingWidget
 
     button.sendEvent(Event(Id(1), EventTag(3456543)))
 
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 573d1e44b..396e57af6 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
@@ -31,7 +31,7 @@ import app.cash.redwood.protocol.PropertyDiff
 import app.cash.redwood.protocol.PropertyTag
 import app.cash.redwood.protocol.WidgetTag
 import example.redwood.compose.Button
-import example.redwood.compose.ExampleSchemaDiffProducingWidgetFactories
+import example.redwood.compose.ExampleSchemaProtocolBridge
 import example.redwood.compose.Row
 import example.redwood.compose.Text
 import kotlin.test.Test
@@ -49,7 +49,7 @@ class ProtocolTest {
     val clock = BroadcastFrameClock()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      provider = ExampleSchemaDiffProducingWidgetFactories(ProtocolBridge()),
+      bridge = ExampleSchemaProtocolBridge.create(),
       diffSink = ::error,
       widgetVersion = 22U,
     )
@@ -68,7 +68,7 @@ class ProtocolTest {
     val diffs = ArrayDeque<Diff>()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      provider = ExampleSchemaDiffProducingWidgetFactories(ProtocolBridge()),
+      bridge = ExampleSchemaProtocolBridge.create(),
       diffSink = { diff -> diffs += diff },
       widgetVersion = 1U,
     )
@@ -111,11 +111,11 @@ class ProtocolTest {
   @Test fun protocolSkipsLambdaChangeOfSamePresence() = runTest {
     val clock = BroadcastFrameClock()
     var state by mutableStateOf(0)
-    val bridge = ProtocolBridge()
+    val bridge = ExampleSchemaProtocolBridge.create()
     val diffs = ArrayDeque<Diff>()
     val composition = ProtocolRedwoodComposition(
       scope = this + clock,
-      provider = ExampleSchemaDiffProducingWidgetFactories(bridge),
+      bridge = bridge,
       diffSink = { diff -> diffs += diff },
       widgetVersion = 1U,
     )