Repository: cashapp/redwood
Pull request #959 (2023-04-21 22:18:47)

Assign a string key to every single item in a LazyColumn
This fixes issues with items being recreated repeatedly.

I don't like that we need to create lots of strings with this, but it's a simple fix that's likely to prove useful with other features like keeping scroll position.

I've only honored this for Android views. For follow-up it's necessary to honor it for Compose and iOS.

swankjesse: Ooooh that’s a fantastic idea

Patch:
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
index dbc27d805..c24763f43 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
@@ -18,10 +18,11 @@ package app.cash.redwood.cli
 import app.cash.redwood.tooling.codegen.CodegenType
 import app.cash.redwood.tooling.codegen.ProtocolCodegenType
 import app.cash.redwood.tooling.codegen.generate
-import app.cash.redwood.tooling.schema.parseProtocolSchema
-import app.cash.redwood.tooling.schema.parseSchema
+import app.cash.redwood.tooling.schema.FqType
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.arguments.argument
+import com.github.ajalt.clikt.parameters.arguments.convert
 import com.github.ajalt.clikt.parameters.arguments.help
 import com.github.ajalt.clikt.parameters.options.convert
 import com.github.ajalt.clikt.parameters.options.help
@@ -52,20 +53,27 @@ internal class GenerateCommand : CliktCommand(name = "generate") {
     .convert { it.split(File.pathSeparator).map(::File) }
     .required()
 
-  private val schemaTypeName by argument("schema")
+  private val schemaType by argument("schema")
     .help("Fully-qualified class name for the @Schema-annotated interface")
+    .convert {
+      FqType(
+        listOf(
+          it.substringBeforeLast(".", missingDelimiterValue = ""), // Package name.
+          it.substringAfterLast("."), // Simple name.
+        ),
+      )
+    }
 
   override fun run() {
     val classLoader = URLClassLoader(classpath.map { it.toURI().toURL() }.toTypedArray())
-    val schemaType = classLoader.loadClass(schemaTypeName).kotlin
 
     when (val type = type) {
       is CodegenType -> {
-        val schemaSet = parseSchema(schemaType)
+        val schemaSet = ProtocolSchemaSet.load(schemaType, classLoader)
         schemaSet.generate(type, out)
       }
       is ProtocolCodegenType -> {
-        val schemaSet = parseProtocolSchema(schemaType)
+        val schemaSet = ProtocolSchemaSet.load(schemaType, classLoader)
         schemaSet.generate(type, out)
       }
       else -> throw AssertionError()
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/JsonCommand.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/JsonCommand.kt
index 4d095077b..c0c1b6665 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/JsonCommand.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/JsonCommand.kt
@@ -15,7 +15,7 @@
  */
 package app.cash.redwood.cli
 
-import app.cash.redwood.tooling.schema.parseProtocolSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.arguments.argument
 import com.github.ajalt.clikt.parameters.arguments.help
@@ -46,7 +46,7 @@ internal class JsonCommand : CliktCommand(
   override fun run() {
     val classLoader = URLClassLoader(classpath.map { it.toURI().toURL() }.toTypedArray())
     val schemaType = classLoader.loadClass(schemaTypeName).kotlin
-    val schema = parseProtocolSchema(schemaType).schema
+    val schema = ProtocolSchemaSet.parse(schemaType).schema
     val embeddedSchema = schema.toEmbeddedSchema()
     val path = out.resolve(embeddedSchema.path)
     path.parent.createDirectories()
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
index 36ad990c6..72a2e6955 100644
--- a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolBridge.kt
@@ -37,11 +37,11 @@ import kotlin.native.ObjCName
 @ObjCName("ProtocolBridge", exact = true)
 public class ProtocolBridge<W : Any>(
   container: Widget.Children<W>,
-  private val factory: DiffConsumingNode.Factory<W>,
+  private val factory: ProtocolNode.Factory<W>,
   private val eventSink: EventSink,
 ) : DiffSink {
-  private val nodes = mutableMapOf<Id, DiffConsumingNode<W>>(
-    Id.Root to DiffConsumingProtocolRoot(container),
+  private val nodes = mutableMapOf<Id, ProtocolNode<W>>(
+    Id.Root to RootProtocolNode(container),
   )
 
   override fun sendDiff(diff: Diff) {
@@ -86,14 +86,14 @@ public class ProtocolBridge<W : Any>(
     }
   }
 
-  private fun node(id: Id): DiffConsumingNode<W> {
+  private fun node(id: Id): ProtocolNode<W> {
     return checkNotNull(nodes[id]) { "Unknown widget ID $id" }
   }
 }
 
-private class DiffConsumingProtocolRoot<W : Any>(
+private class RootProtocolNode<W : Any>(
   private val children: Widget.Children<W>,
-) : DiffConsumingNode<W>(
+) : ProtocolNode<W>(
   parentId = Id.Root, // This value is a lie, but it's never accessed on this node.
   parentChildren = children, // This value is a lie, but it's never accessed on this node.
 ) {
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNode.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolNode.kt
similarity index 82%
rename from redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNode.kt
rename to redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolNode.kt
index cb2ddcc04..502ae1cde 100644
--- a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNode.kt
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolNode.kt
@@ -25,12 +25,12 @@ import app.cash.redwood.widget.Widget
 import kotlin.native.ObjCName
 
 /**
- * A [Widget] which consumes protocol diffs and applies them to a platform-specific representation.
+ * A node which consumes protocol diffs and applies them to a platform-specific representation.
  *
  * @suppress
  */
-@ObjCName("DiffConsumingNode", exact = true)
-public abstract class DiffConsumingNode<W : Any>(
+@ObjCName("ProtocolNode", exact = true)
+public abstract class ProtocolNode<W : Any>(
   public val parentId: Id,
   public val parentChildren: Widget.Children<W>,
 ) {
@@ -45,7 +45,7 @@ public abstract class DiffConsumingNode<W : Any>(
   public abstract fun updateLayoutModifier(elements: List<LayoutModifierElement>)
 
   /**
-   * Return one of this widget's children groups by its [tag].
+   * Return one of this node's children groups by its [tag].
    *
    * Invalid [tag] values can either produce an exception or result in `null` being returned.
    * If `null` is returned, the caller should make every effort to ignore these children and
@@ -53,19 +53,19 @@ public abstract class DiffConsumingNode<W : Any>(
    */
   public abstract fun children(tag: ChildrenTag): Widget.Children<W>?
 
-  @ObjCName("DiffConsumingNodeFactory", exact = true)
+  @ObjCName("ProtocolNodeFactory", exact = true)
   public interface Factory<W : Any> {
     /**
-     * Create a new protocol-consuming widget of the specified [tag].
+     * Create a new protocol node of the specified [tag].
      *
      * Invalid [tag] values can either produce an exception or result in `null` being returned.
-     * If `null` is returned, the caller should make every effort to ignore this widget and
+     * If `null` is returned, the caller should make every effort to ignore this node and
      * continue executing.
      */
     public fun create(
       parentId: Id,
       parentChildren: Widget.Children<W>,
       tag: WidgetTag,
-    ): DiffConsumingNode<W>?
+    ): ProtocolNode<W>?
   }
 }
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 21567161c..269a638f4 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
@@ -39,7 +39,7 @@ public fun ProtocolSchemaSet.generate(type: ProtocolCodegenType, destination: Pa
       }
     }
     Widget -> {
-      generateDiffConsumingNodeFactory(this).writeTo(destination)
+      generateProtocolNodeFactory(this).writeTo(destination)
       generateWidgetProtocolLayoutModifierSerialization(this).writeTo(destination)
       for (dependency in all) {
         generateProtocolLayoutModifierImpls(dependency, host = schema).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 78b65025a..acdc0a6b4 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
@@ -84,8 +84,8 @@ internal fun Schema.protocolWidgetType(widget: Widget, host: Schema): ClassName
   return ClassName(composePackage(host), "Protocol${widget.type.flatName}")
 }
 
-internal fun Schema.diffConsumingNodeFactoryType(): ClassName {
-  return ClassName(widgetPackage(), "${type.flatName}DiffConsumingNodeFactory")
+internal fun Schema.protocolNodeFactoryType(): ClassName {
+  return ClassName(widgetPackage(), "${type.flatName}ProtocolNodeFactory")
 }
 
 internal fun Schema.protocolNodeType(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 c554d6867..0d12810b6 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
@@ -50,10 +50,10 @@ internal object ComposeProtocol {
 }
 
 internal object WidgetProtocol {
-  val DiffConsumingNode = ClassName("app.cash.redwood.protocol.widget", "DiffConsumingNode")
-  val DiffConsumingNodeFactory = DiffConsumingNode.nestedClass("Factory")
   val ProtocolMismatchHandler =
     ClassName("app.cash.redwood.protocol.widget", "ProtocolMismatchHandler")
+  val ProtocolNode = ClassName("app.cash.redwood.protocol.widget", "ProtocolNode")
+  val ProtocolNodeFactory = ProtocolNode.nestedClass("Factory")
 }
 
 internal object Redwood {
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetProtocolGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetProtocolGeneration.kt
index 0dbbce56f..11f91ea4e 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetProtocolGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetProtocolGeneration.kt
@@ -36,20 +36,20 @@ import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
 
 /*
-public class SunspotDiffConsumingNodeFactory<W : Any>(
+public class SunspotProtocolNodeFactory<W : Any>(
   private val provider: SunspotWidgetFactoryProvider<W>,
   private val json: Json = Json.Default,
   private val mismatchHandler: ProtocolMismatchHandler = ProtocolMismatchHandler.Throwing,
-) : DiffConsumingNode.Factory<W> {
+) : ProtocolNode.Factory<W> {
   override fun create(
     parentId: Id,
     parentChildren: Widget.Children<W>,
     tag: WidgetTag,
-  ): DiffConsumingNode<W>? = when (tag.value) {
-    1 -> DiffConsumingTextNode(parentId, parentChildren, delegate.Sunspot.Text(), json, mismatchHandler)
-    2 -> DiffConsumingButtonNode(parentId, parentChildren, delegate.Sunspot.Button(), json, mismatchHandler)
-    1_000_001 -> DiffConsumingRedwoodLayoutRowNode(parentId, parentChildren, delegate.RedwoodLayout.Row(), json, mismatchHandler)
-    1_000_002 -> DiffConsumingRedwoodLayoutColumnNode(parentId, parentChildren, delegate.RedwoodLayout.Column(), json, mismatchHandler)
+  ): ProtocolNode<W>? = when (tag.value) {
+    1 -> TextProtocolNode(parentId, parentChildren, delegate.Sunspot.Text(), json, mismatchHandler)
+    2 -> ButtonProtocolNode(parentId, parentChildren, delegate.Sunspot.Button(), json, mismatchHandler)
+    1_000_001 -> RedwoodLayoutRowProtocolNode(parentId, parentChildren, delegate.RedwoodLayout.Row(), json, mismatchHandler)
+    1_000_002 -> RedwoodLayoutColumnProtocolNode(parentId, parentChildren, delegate.RedwoodLayout.Column(), json, mismatchHandler)
     else -> {
       mismatchHandler.onUnknownWidget(tag)
       null
@@ -57,17 +57,17 @@ public class SunspotDiffConsumingNodeFactory<W : Any>(
   }
 }
 */
-internal fun generateDiffConsumingNodeFactory(
+internal fun generateProtocolNodeFactory(
   schemaSet: ProtocolSchemaSet,
 ): FileSpec {
   val schema = schemaSet.schema
   val provider = schema.getWidgetFactoryProviderType().parameterizedBy(typeVariableW)
-  val type = schema.diffConsumingNodeFactoryType()
+  val type = schema.protocolNodeFactoryType()
   return FileSpec.builder(type)
     .addType(
       TypeSpec.classBuilder(type)
         .addTypeVariable(typeVariableW)
-        .addSuperinterface(WidgetProtocol.DiffConsumingNodeFactory.parameterizedBy(typeVariableW))
+        .addSuperinterface(WidgetProtocol.ProtocolNodeFactory.parameterizedBy(typeVariableW))
         .primaryConstructor(
           FunSpec.constructorBuilder()
             .addParameter("provider", provider)
@@ -105,7 +105,7 @@ internal fun generateDiffConsumingNodeFactory(
             .addParameter("parentChildren", RedwoodWidget.WidgetChildrenOfW)
             .addParameter("tag", Protocol.WidgetTag)
             .returns(
-              WidgetProtocol.DiffConsumingNode.parameterizedBy(typeVariableW)
+              WidgetProtocol.ProtocolNode.parameterizedBy(typeVariableW)
                 .copy(nullable = true),
             )
             .beginControlFlow("return when (tag.value)")
@@ -139,7 +139,7 @@ internal class ProtocolButton<W : Any>(
   private val delegate: Button<W>,
   private val json: Json,
   private val mismatchHandler: ProtocolMismatchHandler,
-) : DiffConsumingNode<W> {
+) : ProtocolNode<W> {
   public override val value: W get() = delegate.value
 
   public override val layoutModifiers: LayoutModifier
@@ -173,7 +173,7 @@ internal fun generateProtocolNode(
 ): FileSpec {
   val type = schema.protocolNodeType(widget, host)
   val widgetType = schema.widgetType(widget).parameterizedBy(typeVariableW)
-  val protocolType = WidgetProtocol.DiffConsumingNode.parameterizedBy(typeVariableW)
+  val protocolType = WidgetProtocol.ProtocolNode.parameterizedBy(typeVariableW)
   return FileSpec.builder(type)
     .addType(
       TypeSpec.classBuilder(type)
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaApi.kt
similarity index 93%
rename from redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt
rename to redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaApi.kt
index b1652732f..90b0d858d 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaApi.kt
@@ -19,6 +19,7 @@ import app.cash.redwood.tooling.schema.Widget.Children
 import app.cash.redwood.tooling.schema.Widget.Event
 import app.cash.redwood.tooling.schema.Widget.Property
 import app.cash.redwood.tooling.schema.Widget.Trait
+import kotlin.reflect.KClass
 
 /** A [Schema] and its dependencies. */
 public interface SchemaSet {
@@ -37,12 +38,6 @@ public interface Schema {
   public val widgets: List<Widget>
   public val layoutModifiers: List<LayoutModifier>
   public val dependencies: List<FqType>
-
-  /**
-   * Convert this schema to JSON which can be embedded inside the schema artifact.
-   * This JSON will be read when the schema is used as a dependency.
-   */
-  public fun toEmbeddedSchema(): EmbeddedSchema
 }
 
 public data class EmbeddedSchema(
@@ -124,6 +119,16 @@ public interface ProtocolSchemaSet : SchemaSet {
     add(schema)
     addAll(dependencies.values)
   }
+
+  public companion object {
+    public fun load(type: FqType, classLoader: ClassLoader): ProtocolSchemaSet {
+      return loadProtocolSchemaSet(type, classLoader)
+    }
+
+    public fun parse(type: KClass<*>): ProtocolSchemaSet {
+      return parseProtocolSchemaSet(type)
+    }
+  }
 }
 
 public interface ProtocolSchema : Schema {
@@ -131,6 +136,12 @@ public interface ProtocolSchema : Schema {
   override val layoutModifiers: List<ProtocolLayoutModifier>
   override val dependencies: List<FqType> get() = taggedDependencies.values.toList()
   public val taggedDependencies: Map<Int, FqType>
+
+  /**
+   * Convert this schema to JSON which can be embedded inside the schema artifact.
+   * This JSON will be read when the schema is used as a dependency.
+   */
+  public fun toEmbeddedSchema(): EmbeddedSchema
 }
 
 public interface ProtocolWidget : Widget {
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaImpls.kt
similarity index 100%
rename from redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt
rename to redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaImpls.kt
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
index 4f06b2223..40a505384 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
@@ -48,11 +48,47 @@ private val KClass<*>.schemaAnnotation: SchemaAnnotation get() {
   return requireNotNull(findAnnotation()) { "Schema $qualifiedName missing @Schema annotation" }
 }
 
-public fun parseSchema(schemaType: KClass<*>): SchemaSet {
-  return parseProtocolSchema(schemaType)
+internal fun loadProtocolSchemaSet(
+  type: FqType,
+  classLoader: ClassLoader,
+): ProtocolSchemaSet {
+  val schema = loadProtocolSchema(type, classLoader)
+  val dependencies = schema.taggedDependencies.map { (tag, dependency) ->
+    loadProtocolSchema(dependency, classLoader, tag)
+  }
+  return ParsedProtocolSchemaSet(
+    schema = schema,
+    dependencies = dependencies.associateBy { it.type },
+  )
+}
+
+internal fun loadProtocolSchema(
+  type: FqType,
+  classLoader: ClassLoader,
+  tag: Int = 0,
+): ProtocolSchema {
+  require(tag in 0..maxSchemaTag) {
+    "$type tag must be 0 for the root or in range (0, $maxSchemaTag] as a dependency: $tag"
+  }
+  val tagOffset = tag * maxMemberTag
+
+  val path = ParsedProtocolSchema.toEmbeddedPath(type)
+  val schema = classLoader
+    .getResourceAsStream(path)
+    ?.use(InputStream::readBytes)
+    ?.decodeToString()
+    ?.let { json -> ParsedProtocolSchema.parseEmbeddedJson(json, tagOffset) }
+    ?: throw IllegalArgumentException("Unable to locate JSON for $type at $path")
+
+  require(tag == 0 || schema.dependencies.isEmpty()) {
+    "Schema dependency $type also has its own dependencies. " +
+      "For now, only a single level of dependencies is supported."
+  }
+
+  return schema
 }
 
-public fun parseProtocolSchema(schemaType: KClass<*>): ProtocolSchemaSet {
+internal fun parseProtocolSchemaSet(schemaType: KClass<*>): ProtocolSchemaSet {
   val schemaAnnotation = schemaType.schemaAnnotation
   val memberTypes = schemaAnnotation.members
 
@@ -157,25 +193,16 @@ public fun parseProtocolSchema(schemaType: KClass<*>): ProtocolSchemaSet {
   val dependencies = schemaAnnotation.dependencies
     .associate {
       val dependencyTag = it.tag
-      require(dependencyTag in 1..maxSchemaTag) {
-        "Dependency ${it.schema.qualifiedName} tag must be in range (0, $maxSchemaTag]: $dependencyTag"
+      val dependencyType = it.schema.toFqType()
+      require(dependencyTag != 0) {
+        "Dependency $dependencyType tag must not be non-zero"
       }
-      val tagOffset = dependencyTag * maxMemberTag
-
-      val path = ParsedProtocolSchema.toEmbeddedPath(it.schema.toFqType())
-      val schema = schemaType.java
-        .getResourceAsStream("/$path")
-        ?.use(InputStream::readBytes)
-        ?.decodeToString()
-        ?.let { json -> ParsedProtocolSchema.parseEmbeddedJson(json, tagOffset) }
-        ?: throw IllegalArgumentException(
-          "Unable to locate JSON for ${it.schema.qualifiedName} at $path",
-        )
 
-      require(schema.dependencies.isEmpty()) {
-        "Schema dependency ${it.schema.qualifiedName} also has its own dependencies. " +
-          "For now, only a single level of dependencies is supported."
-      }
+      val schema = loadProtocolSchema(
+        type = dependencyType,
+        classLoader = schemaType.java.classLoader,
+        tag = dependencyTag,
+      )
       dependencyTag to schema
     }
 
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
index e011f34d0..d566c4d8c 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
@@ -18,8 +18,8 @@ package app.cash.redwood.treehouse
 import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.Event
 import app.cash.redwood.protocol.EventSink
-import app.cash.redwood.protocol.widget.DiffConsumingNode
 import app.cash.redwood.protocol.widget.ProtocolBridge
+import app.cash.redwood.protocol.widget.ProtocolNode
 import app.cash.redwood.widget.Widget
 import app.cash.zipline.ZiplineScope
 import app.cash.zipline.withScope
@@ -286,7 +286,7 @@ private class ViewContentCodeBinding<A : AppService>(
       factory = view.widgetSystem.widgetFactory(
         json = json,
         protocolMismatchHandler = eventPublisher.protocolMismatchHandler(app),
-      ) as DiffConsumingNode.Factory<Any>,
+      ) as ProtocolNode.Factory<Any>,
       eventSink = this,
     )
 
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
index 4bd8cf4a1..6f4435a10 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
@@ -15,8 +15,8 @@
  */
 package app.cash.redwood.treehouse
 
-import app.cash.redwood.protocol.widget.DiffConsumingNode
 import app.cash.redwood.protocol.widget.ProtocolMismatchHandler
+import app.cash.redwood.protocol.widget.ProtocolNode
 import app.cash.redwood.widget.Widget
 import kotlin.native.ObjCName
 import kotlinx.coroutines.flow.StateFlow
@@ -45,6 +45,6 @@ public interface TreehouseView {
     public fun widgetFactory(
       json: Json,
       protocolMismatchHandler: ProtocolMismatchHandler,
-    ): DiffConsumingNode.Factory<*>
+    ): ProtocolNode.Factory<*>
   }
 }
diff --git a/redwood-treehouse-lazylayout-api/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/api/LazyListIntervalContent.kt b/redwood-treehouse-lazylayout-api/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/api/LazyListIntervalContent.kt
index f0bc0d70d..1812f74cc 100644
--- a/redwood-treehouse-lazylayout-api/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/api/LazyListIntervalContent.kt
+++ b/redwood-treehouse-lazylayout-api/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/api/LazyListIntervalContent.kt
@@ -24,9 +24,10 @@ import kotlinx.serialization.Serializable
 @Serializable
 @ObjCName("LazyListInterval", exact = true)
 public class LazyListInterval(
-  @Contextual public val count: Int,
+  public val keys: List<String>,
   @Contextual public val itemProvider: Item,
 ) {
+  public val count: Int = keys.size
 
   @ObjCName("LazyListIntervalItem", exact = true)
   public interface Item : ZiplineService {
diff --git a/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyDsl.kt b/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyDsl.kt
index 8acb542ce..bd9a6dc2a 100644
--- a/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyDsl.kt
+++ b/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyDsl.kt
@@ -22,47 +22,52 @@ import app.cash.redwood.treehouse.StandardAppLifecycle
 @LayoutScopeMarker
 public interface LazyListScope {
   public fun item(
+    key: String,
     content: @Composable () -> Unit,
   )
 
   public fun items(
-    count: Int,
+    keys: List<String>,
     itemContent: @Composable (index: Int) -> Unit,
   )
 }
 
 public inline fun <T> LazyListScope.items(
   items: List<T>,
+  itemToKey: (item: T) -> String,
   crossinline itemContent: @Composable (item: T) -> Unit,
 ): Unit = items(
-  count = items.size,
+  keys = items.map(itemToKey),
 ) {
   itemContent(items[it])
 }
 
 public inline fun <T> LazyListScope.itemsIndexed(
   items: List<T>,
+  itemToKey: (item: T) -> String,
   crossinline itemContent: @Composable (index: Int, item: T) -> Unit,
 ): Unit = items(
-  count = items.size,
+  keys = items.map(itemToKey),
 ) {
   itemContent(it, items[it])
 }
 
 public inline fun <T> LazyListScope.items(
   items: Array<T>,
+  itemToKey: (item: T) -> String,
   crossinline itemContent: @Composable (item: T) -> Unit,
 ): Unit = items(
-  count = items.size,
+  keys = items.map(itemToKey),
 ) {
   itemContent(items[it])
 }
 
 public inline fun <T> LazyListScope.itemsIndexed(
   items: Array<T>,
+  itemToKey: (item: T) -> String,
   crossinline itemContent: @Composable (index: Int, item: T) -> Unit,
 ): Unit = items(
-  count = items.size,
+  keys = items.map(itemToKey),
 ) {
   itemContent(it, items[it])
 }
diff --git a/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyListIntervalContent.kt b/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyListIntervalContent.kt
index 25e17cd73..7b1ef3661 100644
--- a/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyListIntervalContent.kt
+++ b/redwood-treehouse-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/compose/LazyListIntervalContent.kt
@@ -39,18 +39,21 @@ internal class LazyListIntervalContent(
   }
 
   override fun items(
-    count: Int,
+    keys: List<String>,
     itemContent: @Composable (index: Int) -> Unit,
   ) {
     intervals += LazyListInterval(
-      count,
-      itemProvider = Item(appLifecycle, itemContent),
+      keys,
+      Item(appLifecycle, itemContent),
     )
   }
 
-  override fun item(content: @Composable () -> Unit) {
+  override fun item(
+    key: String,
+    content: @Composable () -> Unit,
+  ) {
     intervals += LazyListInterval(
-      1,
+      listOf(key),
       Item(appLifecycle) { content() },
     )
   }
diff --git a/redwood-treehouse-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyList.kt b/redwood-treehouse-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyList.kt
index 53106b0c6..ebb1c67c6 100644
--- a/redwood-treehouse-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyList.kt
+++ b/redwood-treehouse-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyList.kt
@@ -60,7 +60,7 @@ internal class ComposeUiLazyList<A : AppService>(
     val itemBoxModifier = if (isVertical) Modifier.height(64.dp) else Modifier.width(64.dp)
     val content: LazyListScope.() -> Unit = {
       intervals.forEach { interval ->
-        items(interval.count) { index ->
+        items(interval.keys.size) { index ->
           Box(itemBoxModifier) {
             TreehouseContent(treehouseApp, widgetSystem) { interval.itemProvider.get(index) }
           }
diff --git a/redwood-treehouse-lazylayout-paging/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/paging/LazyPagingItems.kt b/redwood-treehouse-lazylayout-paging/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/paging/LazyPagingItems.kt
index 7e8a52d71..91508a993 100644
--- a/redwood-treehouse-lazylayout-paging/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/paging/LazyPagingItems.kt
+++ b/redwood-treehouse-lazylayout-paging/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/paging/LazyPagingItems.kt
@@ -31,11 +31,10 @@ import app.cash.redwood.treehouse.lazylayout.compose.LazyListScope
  */
 public fun <T : Any> LazyListScope.items(
   items: LazyPagingItems<T>,
+  itemToKey: (item: T?) -> String,
   itemContent: @Composable (value: T?) -> Unit,
 ) {
-  items(
-    count = items.itemCount,
-  ) { index ->
+  items(items.keys(itemToKey)) { index ->
     itemContent(items[index])
   }
 }
@@ -53,11 +52,19 @@ public fun <T : Any> LazyListScope.items(
  */
 public fun <T : Any> LazyListScope.itemsIndexed(
   items: LazyPagingItems<T>,
+  itemToKey: (item: T?) -> String,
   itemContent: @Composable (index: Int, value: T?) -> Unit,
 ) {
-  items(
-    count = items.itemCount,
-  ) { index ->
+  items(items.keys(itemToKey)) { index ->
     itemContent(index, items[index])
   }
 }
+
+private fun <T : Any> LazyPagingItems<T>.keys(
+  itemToKey: (item: T?) -> String,
+): List<String> {
+  return List(itemCount) {
+    val item = this[it]
+    itemToKey(item)
+  }
+}
diff --git a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyList.kt b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyList.kt
index f46e563a4..facd5f0a7 100644
--- a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyList.kt
+++ b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyList.kt
@@ -73,7 +73,7 @@ private class TableViewDataSource<A : AppService>(
   }
 
   override fun tableView(tableView: UITableView, numberOfRowsInSection: NSInteger): NSInteger {
-    return intervals[numberOfRowsInSection.toInt()].count.toLong()
+    return intervals[numberOfRowsInSection.toInt()].keys.size.toLong()
   }
 
   override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell {
diff --git a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyList.kt b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyList.kt
index 263c55b81..53166770a 100644
--- a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyList.kt
+++ b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyList.kt
@@ -34,7 +34,6 @@ import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.Content
 import app.cash.redwood.treehouse.HostConfiguration
 import app.cash.redwood.treehouse.TreehouseApp
-import app.cash.redwood.treehouse.TreehouseContentSource
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
 import app.cash.redwood.treehouse.TreehouseWidgetView
 import app.cash.redwood.treehouse.lazylayout.api.LazyListInterval
@@ -95,13 +94,13 @@ internal class ViewLazyList<A : AppService>(
   private class ItemPagingSource<A : AppService>(
     private val treehouseApp: TreehouseApp<A>,
     private val intervals: List<LazyListInterval>,
-  ) : PagingSource<Int, Content>() {
+  ) : PagingSource<Int, KeyAndContent>() {
 
     override suspend fun load(
       params: LoadParams<Int>,
-    ): LoadResult<Int, Content> {
+    ): LoadResult<Int, KeyAndContent> {
       val key = params.key ?: 0
-      val count = intervals.sumOf(LazyListInterval::count)
+      val count = intervals.sumOf { it.keys.size }
       val limit = when (params) {
         is LoadParams.Prepend<*> -> minOf(key, params.loadSize)
         else -> params.loadSize
@@ -114,19 +113,16 @@ internal class ViewLazyList<A : AppService>(
       val nextPosToLoad = offset + limit
       val loadResult = LoadResult.Page(
         data = List(if (nextPosToLoad <= count) limit else count - offset) { index ->
-          val itemContentSource = TreehouseContentSource<A> {
-            var interval = IndexedValue(index + offset, intervals.first())
-            for (nextInterval in intervals.drop(1)) {
-              if (interval.index < interval.value.count) break
-              interval = IndexedValue(interval.index - interval.value.count, nextInterval)
-            }
-            interval.value.itemProvider.get(interval.index)
-          }
-          treehouseApp.createContent(itemContentSource).apply {
-            // TODO Create a public API that accepts an Android Context and returns the corresponding HostConfiguration
-            preload(HostConfiguration(darkMode = true))
-            awaitContent()
-          }
+          val (indexInInterval, interval) = findInterval(index + offset)
+          val content = treehouseApp.createContent(
+            source = { interval.itemProvider.get(indexInInterval) },
+          )
+          val keyAndContent = KeyAndContent(
+            key = interval.keys[indexInInterval],
+            content = content,
+          )
+          keyAndContent.awaitContent()
+          keyAndContent
         },
         prevKey = offset.takeIf { it > 0 && limit > 0 },
         nextKey = nextPosToLoad.takeIf { limit > 0 && it < count },
@@ -136,12 +132,38 @@ internal class ViewLazyList<A : AppService>(
       return if (invalid) LoadResult.Invalid() else loadResult
     }
 
-    override fun getRefreshKey(state: PagingState<Int, Content>) = state.anchorPosition
+    private fun findInterval(index: Int): Pair<Int, LazyListInterval> {
+      var i = index
+      for (interval in intervals) {
+        if (i < interval.keys.size) return (i to interval)
+        i -= interval.keys.size
+      }
+      throw IllegalArgumentException()
+    }
+
+    override fun getRefreshKey(state: PagingState<Int, KeyAndContent>) = state.anchorPosition
+  }
+
+  private class KeyAndContent(
+    val key: String,
+    val content: Content,
+  ) {
+    suspend fun awaitContent() {
+      // TODO Create a public API that accepts an Android Context and returns the corresponding HostConfiguration
+      content.preload(HostConfiguration(darkMode = true))
+      content.awaitContent()
+    }
+
+    override fun equals(other: Any?) = other is KeyAndContent && key == other.key
+
+    override fun hashCode() = key.hashCode()
+
+    override fun toString() = "TreehouseItem($key)"
   }
 
   private class LazyContentItemListAdapter(
     private val widgetSystem: WidgetSystem,
-  ) : PagingDataAdapter<Content, ViewHolder>(ContentDiffCallback) {
+  ) : PagingDataAdapter<KeyAndContent, ViewHolder>(ContentDiffCallback) {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder(
       TreehouseWidgetView(parent.context, widgetSystem).apply {
         layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
@@ -152,26 +174,26 @@ internal class ViewLazyList<A : AppService>(
 
     override fun onViewRecycled(holder: ViewHolder) {
       if (holder.bindingAdapterPosition != RecyclerView.NO_POSITION) {
-        getItem(holder.bindingAdapterPosition)!!.unbind()
+        getItem(holder.bindingAdapterPosition)!!.content.unbind()
       }
     }
 
     override fun onBindViewHolder(holder: ViewHolder, position: Int) {
-      getItem(position)!!.bind(holder.treehouseWidgetView)
+      getItem(position)!!.content.bind(holder.treehouseWidgetView)
     }
   }
 
   private class ViewHolder(val treehouseWidgetView: TreehouseWidgetView) : RecyclerView.ViewHolder(treehouseWidgetView)
 
-  private object ContentDiffCallback : DiffUtil.ItemCallback<Content>() {
+  private object ContentDiffCallback : DiffUtil.ItemCallback<KeyAndContent>() {
     override fun areItemsTheSame(
-      oldItem: Content,
-      newItem: Content,
-    ) = oldItem === newItem
+      oldItem: KeyAndContent,
+      newItem: KeyAndContent,
+    ) = oldItem == newItem
 
     override fun areContentsTheSame(
-      oldItem: Content,
-      newItem: Content,
+      oldItem: KeyAndContent,
+      newItem: KeyAndContent,
     ) = oldItem == newItem
   }
 }
diff --git a/samples/emoji-search/android-composeui/src/main/java/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt b/samples/emoji-search/android-composeui/src/main/java/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt
index c8263d2ed..517ff3cea 100644
--- a/samples/emoji-search/android-composeui/src/main/java/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt
+++ b/samples/emoji-search/android-composeui/src/main/java/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt
@@ -34,7 +34,7 @@ import app.cash.zipline.loader.asZiplineHttpClient
 import app.cash.zipline.loader.withDevelopmentServerPush
 import com.example.redwood.emojisearch.launcher.EmojiSearchAppSpec
 import com.example.redwood.emojisearch.treehouse.EmojiSearchPresenter
-import com.example.redwood.emojisearch.widget.EmojiSearchDiffConsumingNodeFactory
+import com.example.redwood.emojisearch.widget.EmojiSearchProtocolNodeFactory
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactories
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.cancel
@@ -56,7 +56,7 @@ class EmojiSearchActivity : ComponentActivity() {
       override fun widgetFactory(
         json: Json,
         protocolMismatchHandler: ProtocolMismatchHandler,
-      ) = EmojiSearchDiffConsumingNodeFactory<@Composable () -> Unit>(
+      ) = EmojiSearchProtocolNodeFactory<@Composable () -> Unit>(
         provider = EmojiSearchWidgetFactories(
           EmojiSearch = AndroidEmojiSearchWidgetFactory(treehouseApp),
           RedwoodLayout = ComposeUiRedwoodLayoutWidgetFactory(),
diff --git a/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
index e62f3b2e8..c8b6b13df 100644
--- a/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
+++ b/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
@@ -32,7 +32,7 @@ import app.cash.zipline.loader.asZiplineHttpClient
 import app.cash.zipline.loader.withDevelopmentServerPush
 import com.example.redwood.emojisearch.launcher.EmojiSearchAppSpec
 import com.example.redwood.emojisearch.treehouse.EmojiSearchPresenter
-import com.example.redwood.emojisearch.widget.EmojiSearchDiffConsumingNodeFactory
+import com.example.redwood.emojisearch.widget.EmojiSearchProtocolNodeFactory
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactories
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.cancel
@@ -54,7 +54,7 @@ class EmojiSearchActivity : ComponentActivity() {
       override fun widgetFactory(
         json: Json,
         protocolMismatchHandler: ProtocolMismatchHandler,
-      ) = EmojiSearchDiffConsumingNodeFactory(
+      ) = EmojiSearchProtocolNodeFactory(
         provider = EmojiSearchWidgetFactories(
           EmojiSearch = AndroidEmojiSearchWidgetFactory(
             context = context,
diff --git a/samples/emoji-search/browser/src/main/kotlin/com/example/redwood/emojisearch/browser/main.kt b/samples/emoji-search/browser/src/main/kotlin/com/example/redwood/emojisearch/browser/main.kt
index 6c5d8faae..a220f7613 100644
--- a/samples/emoji-search/browser/src/main/kotlin/com/example/redwood/emojisearch/browser/main.kt
+++ b/samples/emoji-search/browser/src/main/kotlin/com/example/redwood/emojisearch/browser/main.kt
@@ -78,6 +78,7 @@ private object TruncatingColumnProvider : ColumnProvider {
   @Composable
   override fun <T> create(
     items: List<T>,
+    itemToKey: (item: T) -> String,
     itemContent: @Composable (item: T) -> Unit,
   ) {
     Column {
diff --git a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
index 7384dbe3e..8ac3948a0 100644
--- a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
+++ b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
@@ -26,7 +26,7 @@ import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
 import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.treehouse.lazylayout.uiview.UIViewRedwoodTreehouseLazyLayoutWidgetFactory
-import com.example.redwood.emojisearch.widget.EmojiSearchDiffConsumingNodeFactory
+import com.example.redwood.emojisearch.widget.EmojiSearchProtocolNodeFactory
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactories
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactory
 import okio.ByteString
@@ -38,12 +38,12 @@ import platform.Foundation.NSData
 fun exposedTypes(
   emojiSearchLauncher: EmojiSearchLauncher,
   emojiSearchWidgetFactory: EmojiSearchWidgetFactory<*>,
+  protocolNodeFactory: EmojiSearchProtocolNodeFactory<*>,
   treehouseUIKitView: TreehouseUIKitView,
   uiViewRedwoodLayoutWidgetFactory: UIViewRedwoodLayoutWidgetFactory,
   uiViewRedwoodTreehouseLazyLayoutWidgetFactory: UIViewRedwoodTreehouseLazyLayoutWidgetFactory<*>,
   widgetSystem: WidgetSystem,
   widgetFactories: EmojiSearchWidgetFactories<*>,
-  diffConsumingNodeFactory: EmojiSearchDiffConsumingNodeFactory<*>,
 ) {
   throw AssertionError()
 }
diff --git a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
index 3cbfdd16c..5940cc497 100644
--- a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
+++ b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
@@ -63,8 +63,8 @@ class EmojiSearchWidgetSystem : TreehouseViewWidgetSystem {
     func widgetFactory(
         json: Kotlinx_serialization_jsonJson,
         protocolMismatchHandler: ProtocolMismatchHandler
-    ) -> DiffConsumingNodeFactory {
-        return ProtocolEmojiSearchDiffConsumingNodeFactory<UIView>(
+    ) -> ProtocolNodeFactory {
+        return ProtocolEmojiSearchProtocolNodeFactory<UIView>(
             provider: WidgetEmojiSearchWidgetFactories<UIView>(
                 EmojiSearch: IosEmojiSearchWidgetFactory(treehouseApp: treehouseApp, widgetSystem: self),
                 RedwoodLayout: UIViewRedwoodLayoutWidgetFactory(),
diff --git a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
index 3915c8ea2..1b1e659f5 100644
--- a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
+++ b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
@@ -57,6 +57,7 @@ interface ColumnProvider {
   @Composable
   fun <T> create(
     items: List<T>,
+    itemToKey: (item: T) -> String,
     itemContent: @Composable (item: T) -> Unit,
   )
 }
@@ -103,7 +104,10 @@ fun EmojiSearch(
       hint = "Search",
       onChange = { searchTerm = it },
     )
-    columnProvider.create(filteredEmojis) { image ->
+    columnProvider.create(
+      filteredEmojis,
+      itemToKey = { it.label },
+    ) { image ->
       Row(
         width = Constraint.Fill,
         verticalAlignment = CrossAxisAlignment.Center,
diff --git a/samples/emoji-search/presenter/src/jsMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTreehouseUi.kt b/samples/emoji-search/presenter/src/jsMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTreehouseUi.kt
index f028ca6b6..261555a1e 100644
--- a/samples/emoji-search/presenter/src/jsMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTreehouseUi.kt
+++ b/samples/emoji-search/presenter/src/jsMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTreehouseUi.kt
@@ -39,10 +39,11 @@ private class LazyColumnProvider(
   @Composable
   override fun <T> create(
     items: List<T>,
+    itemToKey: (item: T) -> String,
     itemContent: @Composable (item: T) -> Unit,
   ) {
     LazyColumn(appLifecycle) {
-      items(items, itemContent)
+      items(items, itemToKey, itemContent)
     }
   }
 }
diff --git a/samples/repo-search/android-views/src/main/java/com/example/redwood/reposearch/android/views/RepoSearchActivity.kt b/samples/repo-search/android-views/src/main/java/com/example/redwood/reposearch/android/views/RepoSearchActivity.kt
index e6a1def32..4aa93c9af 100644
--- a/samples/repo-search/android-views/src/main/java/com/example/redwood/reposearch/android/views/RepoSearchActivity.kt
+++ b/samples/repo-search/android-views/src/main/java/com/example/redwood/reposearch/android/views/RepoSearchActivity.kt
@@ -32,7 +32,7 @@ import app.cash.zipline.loader.asZiplineHttpClient
 import app.cash.zipline.loader.withDevelopmentServerPush
 import com.example.redwood.reposearch.launcher.RepoSearchAppSpec
 import com.example.redwood.reposearch.treehouse.RepoSearchPresenter
-import com.example.redwood.reposearch.widget.RepoSearchDiffConsumingNodeFactory
+import com.example.redwood.reposearch.widget.RepoSearchProtocolNodeFactory
 import com.example.redwood.reposearch.widget.RepoSearchWidgetFactories
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.cancel
@@ -54,7 +54,7 @@ class RepoSearchActivity : ComponentActivity() {
       override fun widgetFactory(
         json: Json,
         protocolMismatchHandler: ProtocolMismatchHandler,
-      ) = RepoSearchDiffConsumingNodeFactory(
+      ) = RepoSearchProtocolNodeFactory(
         provider = RepoSearchWidgetFactories(
           RepoSearch = AndroidRepoSearchWidgetFactory(context),
           RedwoodLayout = ViewRedwoodLayoutWidgetFactory(context),
diff --git a/samples/repo-search/presenter/src/jsMain/kotlin/com/example/redwood/reposearch/presenter/RepoSearchTreehouseUi.kt b/samples/repo-search/presenter/src/jsMain/kotlin/com/example/redwood/reposearch/presenter/RepoSearchTreehouseUi.kt
index 94e1c712c..be2a0fc78 100644
--- a/samples/repo-search/presenter/src/jsMain/kotlin/com/example/redwood/reposearch/presenter/RepoSearchTreehouseUi.kt
+++ b/samples/repo-search/presenter/src/jsMain/kotlin/com/example/redwood/reposearch/presenter/RepoSearchTreehouseUi.kt
@@ -48,7 +48,10 @@ class RepoSearchTreehouseUi(
   override fun Show() {
     val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
     LazyColumn(appLifecycle) {
-      items(lazyPagingItems) {
+      items(
+        items = lazyPagingItems,
+        itemToKey = { it!!.fullName },
+      ) {
         RepoSearch(it!!)
       }
     }


Test patch:
diff --git a/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainerTest.kt b/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainerTest.kt
index 6a85a3be2..0d8f038f6 100644
--- a/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainerTest.kt
+++ b/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainerTest.kt
@@ -44,6 +44,7 @@ class ComposeUiFlexContainerTest : AbstractFlexContainerTest<@Composable () -> U
   val paparazzi = Paparazzi(
     deviceConfig = DeviceConfig.PIXEL_6,
     theme = "android:Theme.Material.Light.NoActionBar",
+    showSystemUi = false,
   )
 
   override fun flexContainer(direction: FlexDirection) = ComposeTestFlexContainer(direction)
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentCenter.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentCenter.png
index 46d9064ae..60ec3e64e 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentCenter.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentCenter.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:2f0d5568b298259db005ebfe33a3817f4b4bc5d54311906a2b9b76b7425c54be
-size 58208
+oid sha256:c3811d2a2f10af21032783907135da2853b0c0751dffdbf1961bac5c725e47da
+size 55273
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceAround.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceAround.png
index 0d6a93031..f08537ec0 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceAround.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceAround.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:66409be678b4724d7fc2d97cf9373cc351d176f191c3c04ef7253ab02165f829
-size 58637
+oid sha256:0e27dfd02b1114abd0c73ece0c22d3468dbe69ad926186384945c3a7d3e8871a
+size 55071
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceBetween.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceBetween.png
index 0878efa44..e19952201 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceBetween.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithJustifyContentSpaceBetween.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:25772f9965122b14513716a1eb7d856f147544d69cabf2c59e95e4d84f9f47d3
-size 58553
+oid sha256:b20cc7742554f73071e1de9b6b948498de60e55fc73b14e0e8468780a6d8ab90
+size 55832
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithMarginAndDifferentAlignments.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithMarginAndDifferentAlignments.png
index 8f123711b..e6c6b9a46 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithMarginAndDifferentAlignments.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_columnWithMarginAndDifferentAlignments.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:17a9f5e640c7dc00361ef8485d682ab3880764c67dd6f4d6694820c83551a3f3
-size 58455
+oid sha256:7fadd902a58a693b6bd8838f7ca20cdfba4219ed210e3479a5b0650419560b15
+size 55647
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longColumn.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longColumn.png
index 81bcfac13..df21e6ed8 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longColumn.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longColumn.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:4a525c9bc0eca98962eb6c14f6a7126d4510f6d8e282b7b42a7f20b0ef3ef75d
-size 57551
+oid sha256:e84b8bdba6cbb2a84c8bcba44356c87dae452c405643e58367811249dd240fbc
+size 54913
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longRow.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longRow.png
index cc1d13cb3..d3554d744 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longRow.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_longRow.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:7c4899c29d0971098592db3aefbe4350ba371de0b0184975dc1b8df30f94794d
-size 11277
+oid sha256:6ee386a168f7c2685e9594316a4b42fbbfba192e8df2d93e881ac0c08f87ebc6
+size 8107
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortColumn.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortColumn.png
index f4330211d..6f1cd855e 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortColumn.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortColumn.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:a4d8481ac9be30a48c64431f2d7623aac799c973c1d373ee3582fcc77f3f1f8a
-size 19442
+oid sha256:df557299fb750f451225c24f2c524c35f1941010059eb03899919c0622cd4ade
+size 16327
diff --git a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortRow.png b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortRow.png
index ea9471653..b899ee2ef 100644
--- a/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortRow.png
+++ b/redwood-layout-composeui/src/test/snapshots/images/app.cash.redwood.layout.composeui_ComposeUiFlexContainerTest_shortRow.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:58490533159e93f5e21b5be494d797c09a37b590280975a5b053e57a220e4c65
-size 13212
+oid sha256:fcbd3525432e408e98f6f4df6ab7bbe7ba6f8373cdc03e78de3eea168d236d07
+size 10078
diff --git a/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
index 1a93304e4..6ad54c45a 100644
--- a/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
+++ b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
@@ -42,6 +42,7 @@ class ViewFlexContainerTest : AbstractFlexContainerTest<View>() {
   val paparazzi = Paparazzi(
     deviceConfig = DeviceConfig.PIXEL_6,
     theme = "android:Theme.Material.Light.NoActionBar",
+    showSystemUi = false,
   )
 
   override fun flexContainer(direction: FlexDirection) = ViewTestFlexContainer(paparazzi.context, direction)
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentCenter.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentCenter.png
index 44bebb1cd..60ec3e64e 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentCenter.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentCenter.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:a7b18f7e21bf232ee1efc1034b235736e3498800b6d6de7bb64201425ed3f293
-size 58250
+oid sha256:c3811d2a2f10af21032783907135da2853b0c0751dffdbf1961bac5c725e47da
+size 55273
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceAround.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceAround.png
index 232514dd9..f08537ec0 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceAround.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceAround.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:81136f2e53ac2986bfaf7e5c613602db9dc13360d82d0d08f9128a1e8d85ed95
-size 58683
+oid sha256:0e27dfd02b1114abd0c73ece0c22d3468dbe69ad926186384945c3a7d3e8871a
+size 55071
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceBetween.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceBetween.png
index 497375327..e19952201 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceBetween.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithJustifyContentSpaceBetween.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:bb4659734f27ba2e4674e040487b04e0b02bbf6809e3a1460226a74a2d661d0f
-size 58595
+oid sha256:b20cc7742554f73071e1de9b6b948498de60e55fc73b14e0e8468780a6d8ab90
+size 55832
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithMarginAndDifferentAlignments.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithMarginAndDifferentAlignments.png
index ec276a6ac..522b44ea5 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithMarginAndDifferentAlignments.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_columnWithMarginAndDifferentAlignments.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:b0162a025526a6cbee5cc0e81d27ebb41fea0d9f1607458c41e387bc2a1b6211
-size 58138
+oid sha256:9c081fa34850b46f43797b0c16b6814efd86ece66b406a88ec005d2ad5c737a2
+size 55407
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longColumn.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longColumn.png
index 8f0b30345..df21e6ed8 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longColumn.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longColumn.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f6a3304e76c0b8df672bd389a880ee934160152fa8c221679e1ff1d79cb38f5e
-size 57585
+oid sha256:e84b8bdba6cbb2a84c8bcba44356c87dae452c405643e58367811249dd240fbc
+size 54913
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longRow.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longRow.png
index 891db12c5..1cd731ba3 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longRow.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_longRow.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:3c5972dcb6d9ae4c09459fb1de7fbe28c7876dbc3b68cd6b383d89cf69ca7d40
-size 28881
+oid sha256:33fcf9445259b5c07fbd79ca4c1de5d0eb16ff6bc59183f6c3fe3e2eb047dc00
+size 25837
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortColumn.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortColumn.png
index 6ec59f4ea..6f1cd855e 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortColumn.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortColumn.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:88207f43e107c0cad1079e7b70b866fa68ef48be6d851ff26e589a0a96721af2
-size 19487
+oid sha256:df557299fb750f451225c24f2c524c35f1941010059eb03899919c0622cd4ade
+size 16327
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortRow.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortRow.png
index 89b3f1d8f..0083caf7d 100644
--- a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortRow.png
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewFlexContainerTest_shortRow.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:3be3c7de862ead50b22594d7e4aacd6903e7039ef5dab0f217e72b61b324e1c8
-size 18828
+oid sha256:115e0a586d3fc56a8b64c029025aa8b10e7ec812a0ec633f69e1a9d0ec1efa69
+size 15592
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
index f5645c435..4c0806cc1 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolBridgeTest.kt
@@ -21,7 +21,7 @@ import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.Id
 import app.cash.redwood.protocol.WidgetTag
 import app.cash.redwood.widget.MutableListChildren
-import example.redwood.widget.ExampleSchemaDiffConsumingNodeFactory
+import example.redwood.widget.ExampleSchemaProtocolNodeFactory
 import example.redwood.widget.ExampleSchemaWidgetFactories
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -31,7 +31,7 @@ class ProtocolBridgeTest {
   @Test fun insertRootIdThrows() {
     val bridge = ProtocolBridge(
       container = MutableListChildren(),
-      factory = ExampleSchemaDiffConsumingNodeFactory(
+      factory = ExampleSchemaProtocolNodeFactory(
         provider = ExampleSchemaWidgetFactories(
           ExampleSchema = EmptyExampleSchemaWidgetFactory(),
           RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -59,7 +59,7 @@ class ProtocolBridgeTest {
   @Test fun duplicateIdThrows() {
     val bridge = ProtocolBridge(
       container = MutableListChildren(),
-      factory = ExampleSchemaDiffConsumingNodeFactory(
+      factory = ExampleSchemaProtocolNodeFactory(
         provider = ExampleSchemaWidgetFactories(
           ExampleSchema = EmptyExampleSchemaWidgetFactory(),
           RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNodeFactoryTest.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolNodeFactoryTest.kt
similarity index 93%
rename from redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNodeFactoryTest.kt
rename to redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolNodeFactoryTest.kt
index 9da229a28..0c9d01740 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingNodeFactoryTest.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolNodeFactoryTest.kt
@@ -27,7 +27,7 @@ import app.cash.redwood.protocol.PropertyDiff
 import app.cash.redwood.protocol.PropertyTag
 import app.cash.redwood.protocol.WidgetTag
 import example.redwood.compose.TestScope
-import example.redwood.widget.ExampleSchemaDiffConsumingNodeFactory
+import example.redwood.widget.ExampleSchemaProtocolNodeFactory
 import example.redwood.widget.ExampleSchemaWidgetFactories
 import example.redwood.widget.TextInput
 import kotlin.test.Test
@@ -43,9 +43,9 @@ import kotlinx.serialization.json.buildJsonArray
 import kotlinx.serialization.json.buildJsonObject
 import kotlinx.serialization.modules.SerializersModule
 
-class DiffConsumingNodeFactoryTest {
+class ProtocolNodeFactoryTest {
   @Test fun unknownWidgetThrowsDefault() {
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -60,7 +60,7 @@ class DiffConsumingNodeFactoryTest {
 
   @Test fun unknownWidgetCallsHandler() {
     val handler = RecordingProtocolMismatchHandler()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -80,7 +80,7 @@ class DiffConsumingNodeFactoryTest {
       }
     }
     val recordingTextInput = RecordingTextInput()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = object : EmptyExampleSchemaWidgetFactory() {
           override fun TextInput() = recordingTextInput
@@ -114,7 +114,7 @@ class DiffConsumingNodeFactoryTest {
       }
     }
     val recordingTextInput = RecordingTextInput()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = object : EmptyExampleSchemaWidgetFactory() {
           override fun TextInput() = recordingTextInput
@@ -142,7 +142,7 @@ class DiffConsumingNodeFactoryTest {
   }
 
   @Test fun unknownLayoutModifierThrowsDefault() {
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -171,7 +171,7 @@ class DiffConsumingNodeFactoryTest {
     }
     val handler = RecordingProtocolMismatchHandler()
     val recordingTextInput = RecordingTextInput()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = object : EmptyExampleSchemaWidgetFactory() {
           override fun TextInput() = recordingTextInput
@@ -208,7 +208,7 @@ class DiffConsumingNodeFactoryTest {
   }
 
   @Test fun unknownChildrenThrowsDefault() {
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -224,7 +224,7 @@ class DiffConsumingNodeFactoryTest {
 
   @Test fun unknownChildrenCallsHandler() {
     val handler = RecordingProtocolMismatchHandler()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -245,7 +245,7 @@ class DiffConsumingNodeFactoryTest {
       }
     }
     val recordingTextInput = RecordingTextInput()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = object : EmptyExampleSchemaWidgetFactory() {
           override fun TextInput() = recordingTextInput
@@ -263,7 +263,7 @@ class DiffConsumingNodeFactoryTest {
   }
 
   @Test fun unknownPropertyThrowsDefaults() {
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -281,7 +281,7 @@ class DiffConsumingNodeFactoryTest {
 
   @Test fun unknownPropertyCallsHandler() {
     val handler = RecordingProtocolMismatchHandler()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = EmptyExampleSchemaWidgetFactory(),
         RedwoodLayout = EmptyRedwoodLayoutWidgetFactory(),
@@ -302,7 +302,7 @@ class DiffConsumingNodeFactoryTest {
       }
     }
     val recordingTextInput = RecordingTextInput()
-    val factory = ExampleSchemaDiffConsumingNodeFactory(
+    val factory = ExampleSchemaProtocolNodeFactory(
       provider = ExampleSchemaWidgetFactories(
         ExampleSchema = object : EmptyExampleSchemaWidgetFactory() {
           override fun TextInput() = recordingTextInput
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
index 49c614f02..4e80f7548 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
@@ -22,7 +22,7 @@ import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
 import app.cash.redwood.tooling.schema.FqType
-import app.cash.redwood.tooling.schema.parseSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 
@@ -43,7 +43,7 @@ class ComposeGenerationTest {
   )
 
   @Test fun `scoped and unscoped children`() {
-    val schema = parseSchema(ScopedAndUnscopedSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(ScopedAndUnscopedSchema::class).schema
 
     val fileSpec = generateComposable(schema, schema.widgets.single())
     assertThat(fileSpec.toString()).apply {
@@ -53,7 +53,7 @@ class ComposeGenerationTest {
   }
 
   @Test fun `scope is annotated with layout scope marker`() {
-    val schema = parseSchema(ScopedAndUnscopedSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(ScopedAndUnscopedSchema::class).schema
 
     val fileSpec = generateScope(schema, FqType(listOf("example", "RowScope")))
     assertThat(fileSpec.toString()).contains(
@@ -85,7 +85,7 @@ class ComposeGenerationTest {
   )
 
   @Test fun `default is supported for all property types`() {
-    val schema = parseSchema(DefaultSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(DefaultSchema::class).schema
 
     val fileSpec = generateComposable(schema, schema.widgets.single())
     assertThat(fileSpec.toString()).apply {
@@ -109,7 +109,7 @@ class ComposeGenerationTest {
   )
 
   @Test fun `layout modifier is the last non child parameter`() {
-    val schema = parseSchema(MultipleChildSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(MultipleChildSchema::class).schema
 
     val fileSpec = generateComposable(schema, schema.widgets.single())
     assertThat(fileSpec.toString()).contains(
@@ -136,7 +136,7 @@ class ComposeGenerationTest {
   @Test fun deprecation() {
     // NOTE: There's no way to deprecate a parameter to a function.
 
-    val schema = parseSchema(DeprecatedSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(DeprecatedSchema::class).schema
 
     val fileSpec = generateComposable(schema, schema.widgets.single())
     assertThat(fileSpec.toString()).contains(
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeProtocolGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeProtocolGenerationTest.kt
index 34d5b0403..ba5ef8995 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeProtocolGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeProtocolGenerationTest.kt
@@ -18,7 +18,7 @@ package app.cash.redwood.tooling.codegen
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
-import app.cash.redwood.tooling.schema.parseProtocolSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import example.redwood.ExampleSchema
 import org.junit.Test
@@ -38,7 +38,7 @@ class ComposeProtocolGenerationTest {
   )
 
   @Test fun `id property does not collide`() {
-    val schema = parseProtocolSchema(IdPropertyNameCollisionSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(IdPropertyNameCollisionSchema::class).schema
 
     val fileSpec = generateProtocolWidget(schema, schema.widgets.single())
     assertThat(fileSpec.toString()).contains(
@@ -50,7 +50,7 @@ class ComposeProtocolGenerationTest {
   }
 
   @Test fun `dependency layout modifiers are included in serialization`() {
-    val schemaSet = parseProtocolSchema(ExampleSchema::class)
+    val schemaSet = ProtocolSchemaSet.parse(ExampleSchema::class)
 
     val fileSpec = generateComposeProtocolLayoutModifierSerialization(schemaSet)
     assertThat(fileSpec.toString()).apply {
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
index eb0970d58..c0078a8ca 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
@@ -17,7 +17,7 @@ package app.cash.redwood.tooling.codegen
 
 import app.cash.redwood.schema.LayoutModifier
 import app.cash.redwood.schema.Schema
-import app.cash.redwood.tooling.schema.parseSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import example.redwood.compose.TestScope
 import kotlin.DeprecationLevel.ERROR
@@ -45,7 +45,7 @@ class LayoutModifierGenerationTest {
   data class ContentDescription(val text: String)
 
   @Test fun `simple names do not collide`() {
-    val schema = parseSchema(SimpleNameCollisionSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(SimpleNameCollisionSchema::class).schema
 
     val topType = schema.layoutModifiers.single { it.type.flatName == "LayoutModifierGenerationTestContentDescription" }
     val topTypeSpec = generateLayoutModifierInterface(schema, topType)
@@ -69,7 +69,7 @@ class LayoutModifierGenerationTest {
   object ScopedLayoutModifier
 
   @Test fun `layout modifier functions are stable`() {
-    val schema = parseSchema(ScopedModifierSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(ScopedModifierSchema::class).schema
 
     val modifier = schema.layoutModifiers.single { it.type.names.last() == "ScopedLayoutModifier" }
     val scope = modifier.scopes.single { it.names.last() == "LayoutModifierScope" }
@@ -109,7 +109,7 @@ class LayoutModifierGenerationTest {
   )
 
   @Test fun deprecation() {
-    val schema = parseSchema(DeprecatedSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(DeprecatedSchema::class).schema
 
     val modifier = schema.layoutModifiers.single()
     val fileSpec = generateLayoutModifierInterface(schema, modifier)
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
index dcd439e4e..6e8ae6208 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
@@ -20,7 +20,7 @@ import app.cash.redwood.schema.Default
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
-import app.cash.redwood.tooling.schema.parseSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 
@@ -46,7 +46,7 @@ class TestingGenerationTest {
   )
 
   @Test fun `tester happy path`() {
-    val schemaSet = parseSchema(HappyPathSchema::class)
+    val schemaSet = ProtocolSchemaSet.parse(HappyPathSchema::class)
     val schema = schemaSet.schema
 
     val testerFileSpec = generateTester(schemaSet)
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
index c95121161..50094bfba 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
@@ -19,7 +19,7 @@ import app.cash.redwood.schema.Children
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
-import app.cash.redwood.tooling.schema.parseSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import kotlin.DeprecationLevel.ERROR
 import org.junit.Test
@@ -41,7 +41,7 @@ class WidgetGenerationTest {
   data class Button(@Property(1) val text: String)
 
   @Test fun `simple names do not collide`() {
-    val schema = parseSchema(SimpleNameCollisionSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(SimpleNameCollisionSchema::class).schema
 
     val fileSpec = generateWidgetFactory(schema)
     assertThat(fileSpec.toString()).apply {
@@ -51,7 +51,7 @@ class WidgetGenerationTest {
   }
 
   @Test fun tagInWidgetFactoryKDoc() {
-    val schema = parseSchema(SimpleNameCollisionSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(SimpleNameCollisionSchema::class).schema
 
     val fileSpec = generateWidgetFactory(schema)
     assertThat(fileSpec.toString()).contains(
@@ -64,7 +64,7 @@ class WidgetGenerationTest {
   }
 
   @Test fun tagInWidgetKdoc() {
-    val schema = parseSchema(SimpleNameCollisionSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(SimpleNameCollisionSchema::class).schema
     val button = schema.widgets.single { it.type.flatName == "WidgetGenerationTestButton" }
 
     val fileSpec = generateWidget(schema, button)
@@ -109,7 +109,7 @@ class WidgetGenerationTest {
   )
 
   @Test fun deprecation() {
-    val schema = parseSchema(DeprecatedSchema::class).schema
+    val schema = ProtocolSchemaSet.parse(DeprecatedSchema::class).schema
 
     val widget = schema.widgets.single()
     val fileSpec = generateWidget(schema, widget)
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetProtocolGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetProtocolGenerationTest.kt
index eba590e73..d5846818b 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetProtocolGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetProtocolGenerationTest.kt
@@ -18,7 +18,7 @@ package app.cash.redwood.tooling.codegen
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
-import app.cash.redwood.tooling.schema.parseProtocolSchema
+import app.cash.redwood.tooling.schema.ProtocolSchemaSet
 import com.google.common.truth.Truth.assertThat
 import example.redwood.ExampleSchema
 import java.util.regex.Pattern
@@ -49,16 +49,16 @@ class WidgetProtocolGenerationTest {
   data class Node12(@Property(1) val text: String)
 
   @Test fun `names are sorted by their node tags`() {
-    val schema = parseProtocolSchema(SortedByTagSchema::class)
+    val schema = ProtocolSchemaSet.parse(SortedByTagSchema::class)
 
-    val fileSpec = generateDiffConsumingNodeFactory(schema)
+    val fileSpec = generateProtocolNodeFactory(schema)
     assertThat(fileSpec.toString()).containsMatch(
       Pattern.compile("1 ->[^2]+2 ->[^3]+3 ->[^1]+12 ->", MULTILINE),
     )
   }
 
   @Test fun `dependency layout modifiers are included in serialization`() {
-    val schema = parseProtocolSchema(ExampleSchema::class)
+    val schema = ProtocolSchemaSet.parse(ExampleSchema::class)
 
     val fileSpec = generateWidgetProtocolLayoutModifierSerialization(schema)
     assertThat(fileSpec.toString()).apply {
diff --git a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaJsonTest.kt b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaJsonTest.kt
index 8dff16022..2a9d3ba5b 100644
--- a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaJsonTest.kt
+++ b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaJsonTest.kt
@@ -32,12 +32,12 @@ class SchemaJsonTest {
   object MyWidget
 
   @Test fun pathReflectsFqcn() {
-    val embeddedSchema = parseSchema(MySchema::class).schema.toEmbeddedSchema()
+    val embeddedSchema = ProtocolSchemaSet.parse(MySchema::class).schema.toEmbeddedSchema()
     assertThat(embeddedSchema.path).isEqualTo("app/cash/redwood/tooling/schema/SchemaJsonTest.MySchema.json")
   }
 
   @Test fun versioned() {
-    val embeddedSchema = parseSchema(MySchema::class).schema.toEmbeddedSchema()
+    val embeddedSchema = ProtocolSchemaSet.parse(MySchema::class).schema.toEmbeddedSchema()
     assertThat(embeddedSchema.json).contains(""""version": 1,""")
   }
 }
diff --git a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
index dbae13a2b..40933aca6 100644
--- a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
+++ b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
@@ -38,7 +38,7 @@ class SchemaParserTest {
 
   @Test fun nonAnnotatedSchemaThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(NonAnnotationSchema::class)
+      parseProtocolSchemaSet(NonAnnotationSchema::class)
     }.hasMessageThat().isEqualTo(
       "Schema app.cash.redwood.tooling.schema.SchemaParserTest.NonAnnotationSchema missing @Schema annotation",
     )
@@ -56,7 +56,7 @@ class SchemaParserTest {
 
   @Test fun nonAnnotatedWidgetThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(NonAnnotatedWidgetSchema::class)
+      parseProtocolSchemaSet(NonAnnotatedWidgetSchema::class)
     }.hasMessageThat().isEqualTo(
       "app.cash.redwood.tooling.schema.SchemaParserTest.NonAnnotatedMember must be annotated with either @Widget or @LayoutModifier",
     )
@@ -77,7 +77,7 @@ class SchemaParserTest {
 
   @Test fun doubleAnnotatedWidgetThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DoubleAnnotatedWidgetSchema::class)
+      parseProtocolSchemaSet(DoubleAnnotatedWidgetSchema::class)
     }.hasMessageThat().isEqualTo(
       "app.cash.redwood.tooling.schema.SchemaParserTest.DoubleAnnotatedWidget must be annotated with either @Widget or @LayoutModifier",
     )
@@ -109,7 +109,7 @@ class SchemaParserTest {
 
   @Test fun duplicateWidgetTagThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DuplicateWidgetTagSchema::class)
+      parseProtocolSchemaSet(DuplicateWidgetTagSchema::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema @Widget tags must be unique
@@ -145,7 +145,7 @@ class SchemaParserTest {
 
   @Test fun duplicateLayoutModifierTagThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DuplicateLayoutModifierTagSchema::class)
+      parseProtocolSchemaSet(DuplicateLayoutModifierTagSchema::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema @LayoutModifier tags must be unique
@@ -170,7 +170,7 @@ class SchemaParserTest {
 
   @Test fun repeatedWidgetTypeThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(RepeatedWidgetTypeSchema::class)
+      parseProtocolSchemaSet(RepeatedWidgetTypeSchema::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema contains repeated member
@@ -196,7 +196,7 @@ class SchemaParserTest {
 
   @Test fun duplicatePropertyTagThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DuplicatePropertyTagSchema::class)
+      parseProtocolSchemaSet(DuplicatePropertyTagSchema::class)
     }.hasMessageThat().isEqualTo(
       """
       |app.cash.redwood.tooling.schema.SchemaParserTest.DuplicatePropertyTagWidget's @Property tags must be unique
@@ -222,7 +222,7 @@ class SchemaParserTest {
 
   @Test fun duplicateChildrenTagThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DuplicateChildrenTagSchema::class)
+      parseProtocolSchemaSet(DuplicateChildrenTagSchema::class)
     }.hasMessageThat().isEqualTo(
       """
       |app.cash.redwood.tooling.schema.SchemaParserTest.DuplicateChildrenTagWidget's @Children tags must be unique
@@ -248,7 +248,7 @@ class SchemaParserTest {
 
   @Test fun unannotatedPrimaryParameterThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(UnannotatedPrimaryParameterSchema::class)
+      parseProtocolSchemaSet(UnannotatedPrimaryParameterSchema::class)
     }.hasMessageThat().isEqualTo(
       "Unannotated parameter \"unannotated\" on app.cash.redwood.tooling.schema.SchemaParserTest.UnannotatedPrimaryParameterWidget",
     )
@@ -268,7 +268,7 @@ class SchemaParserTest {
 
   @Test fun nonDataClassWidgetThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(NonDataClassWidgetSchema::class)
+      parseProtocolSchemaSet(NonDataClassWidgetSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Widget app.cash.redwood.tooling.schema.SchemaParserTest.NonDataClassWidget must be 'data' class or 'object'",
     )
@@ -288,7 +288,7 @@ class SchemaParserTest {
 
   @Test fun nonDataClassLayoutModifierThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(NonDataClassLayoutModifierSchema::class)
+      parseProtocolSchemaSet(NonDataClassLayoutModifierSchema::class)
     }.hasMessageThat().isEqualTo(
       "@LayoutModifier app.cash.redwood.tooling.schema.SchemaParserTest.NonDataClassLayoutModifier must be 'data' class or 'object'",
     )
@@ -308,7 +308,7 @@ class SchemaParserTest {
 
   @Test fun invalidChildrenTypeThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(InvalidChildrenTypeSchema::class)
+      parseProtocolSchemaSet(InvalidChildrenTypeSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.InvalidChildrenTypeWidget#children must be of type '() -> Unit'",
     )
@@ -328,7 +328,7 @@ class SchemaParserTest {
 
   @Test fun invalidChildrenLambdaReturnTypeThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(InvalidChildrenLambdaReturnTypeSchema::class)
+      parseProtocolSchemaSet(InvalidChildrenLambdaReturnTypeSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.InvalidChildrenLambdaReturnTypeWidget#children must be of type '() -> Unit'",
     )
@@ -348,7 +348,7 @@ class SchemaParserTest {
 
   @Test fun childrenArgumentsInvalid() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ChildrenArgumentsInvalidSchema::class)
+      parseProtocolSchemaSet(ChildrenArgumentsInvalidSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.ChildrenArgumentsInvalidWidget#children lambda type must not have any arguments. " +
         "Found: [kotlin.String]",
@@ -369,7 +369,7 @@ class SchemaParserTest {
 
   @Test fun scopedChildrenArgumentsInvalid() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ScopedChildrenArgumentsInvalidSchema::class)
+      parseProtocolSchemaSet(ScopedChildrenArgumentsInvalidSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.ScopedChildrenArgumentsInvalidWidget#children lambda type must not have any arguments. " +
         "Found: [kotlin.Int]",
@@ -390,7 +390,7 @@ class SchemaParserTest {
 
   @Test fun scopedChildrenInvalid() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ScopedChildrenInvalidSchema::class)
+      parseProtocolSchemaSet(ScopedChildrenInvalidSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.ScopedChildrenInvalidWidget#children lambda receiver can only be a class. " +
         "Found: kotlin.collections.List<kotlin.Int>",
@@ -411,7 +411,7 @@ class SchemaParserTest {
 
   @Test fun scopedChildrenTypeParameterInvalid() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ScopedChildrenTypeParameterInvalidSchema::class)
+      parseProtocolSchemaSet(ScopedChildrenTypeParameterInvalidSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Children app.cash.redwood.tooling.schema.SchemaParserTest.ScopedChildrenTypeParameterInvalidWidget#children lambda receiver can only be a class. " +
         "Found: T",
@@ -432,7 +432,7 @@ class SchemaParserTest {
   )
 
   @Test fun eventTypes() {
-    val schema = parseProtocolSchema(EventTypeSchema::class).schema
+    val schema = parseProtocolSchemaSet(EventTypeSchema::class).schema
     val widget = schema.widgets.single()
     assertThat(widget.traits.single { it.name == "requiredEvent" }).isInstanceOf<Event>()
     assertThat(widget.traits.single { it.name == "optionalEvent" }).isInstanceOf<Event>()
@@ -453,7 +453,7 @@ class SchemaParserTest {
   )
 
   @Test fun eventArguments() {
-    val schema = parseProtocolSchema(EventArgumentsSchema::class).schema
+    val schema = parseProtocolSchemaSet(EventArgumentsSchema::class).schema
     val widget = schema.widgets.single()
 
     val noArguments = widget.traits.single { it.name == "noArguments" } as Event
@@ -478,7 +478,7 @@ class SchemaParserTest {
 
   @Test fun eventArgumentsInvalid() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(EventArgumentsInvalidSchema::class)
+      parseProtocolSchemaSet(EventArgumentsInvalidSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Property app.cash.redwood.tooling.schema.SchemaParserTest.EventArgumentsInvalidWidget#tooManyArguments lambda type can only have zero or one arguments. " +
         "Found: [kotlin.String, kotlin.Boolean, kotlin.Long]",
@@ -496,7 +496,7 @@ class SchemaParserTest {
   object ObjectWidget
 
   @Test fun objectWidget() {
-    val schema = parseProtocolSchema(ObjectSchema::class).schema
+    val schema = parseProtocolSchemaSet(ObjectSchema::class).schema
     val widget = schema.widgets.single()
     assertThat(widget.traits).isEmpty()
   }
@@ -513,7 +513,7 @@ class SchemaParserTest {
 
   @Test fun widgetTagOneMillionThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(OneMillionWidgetSchema::class)
+      parseProtocolSchemaSet(OneMillionWidgetSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Widget app.cash.redwood.tooling.schema.SchemaParserTest.OneMillionWidget " +
         "tag must be in range [1, 1000000): 1000000",
@@ -532,7 +532,7 @@ class SchemaParserTest {
 
   @Test fun widgetTagZeroThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ZeroWidgetSchema::class)
+      parseProtocolSchemaSet(ZeroWidgetSchema::class)
     }.hasMessageThat().isEqualTo(
       "@Widget app.cash.redwood.tooling.schema.SchemaParserTest.ZeroWidget " +
         "tag must be in range [1, 1000000): 0",
@@ -553,7 +553,7 @@ class SchemaParserTest {
 
   @Test fun layoutModifierTagOneMillionThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(OneMillionLayoutModifierSchema::class)
+      parseProtocolSchemaSet(OneMillionLayoutModifierSchema::class)
     }.hasMessageThat().isEqualTo(
       "@LayoutModifier app.cash.redwood.tooling.schema.SchemaParserTest.OneMillionLayoutModifier " +
         "tag must be in range [1, 1000000): 1000000",
@@ -574,7 +574,7 @@ class SchemaParserTest {
 
   @Test fun layoutModifierTagZeroThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(ZeroLayoutModifierSchema::class)
+      parseProtocolSchemaSet(ZeroLayoutModifierSchema::class)
     }.hasMessageThat().isEqualTo(
       "@LayoutModifier app.cash.redwood.tooling.schema.SchemaParserTest.ZeroLayoutModifier " +
         "tag must be in range [1, 1000000): 0",
@@ -596,7 +596,7 @@ class SchemaParserTest {
   )
 
   @Test fun schemaTagDefault() {
-    val schema = parseProtocolSchema(SchemaTag::class).schema
+    val schema = parseProtocolSchemaSet(SchemaTag::class).schema
 
     val widget = schema.widgets.single()
     assertThat(widget.tag).isEqualTo(1)
@@ -616,7 +616,7 @@ class SchemaParserTest {
   object SchemaDependencyTagOffsetsMemberTags
 
   @Test fun schemaTagOffsetsMemberTags() {
-    val schema = parseProtocolSchema(SchemaDependencyTagOffsetsMemberTags::class)
+    val schema = parseProtocolSchemaSet(SchemaDependencyTagOffsetsMemberTags::class)
     val dependency = schema.dependencies.values.single()
 
     val widget = dependency.widgets.single { it.type.names.last() == "Row" }
@@ -633,7 +633,7 @@ class SchemaParserTest {
   @Schema(
     members = [],
     dependencies = [
-      Dependency(2001, SchemaTag::class),
+      Dependency(2001, RedwoodLayout::class),
     ],
   )
   object SchemaDependencyTagTooHigh
@@ -641,22 +641,38 @@ class SchemaParserTest {
   @Schema(
     members = [],
     dependencies = [
-      Dependency(0, SchemaTag::class),
+      Dependency(-1, RedwoodLayout::class),
     ],
   )
   object SchemaDependencyTagTooLow
 
-  @Test fun dependencyTagOutOfRangeThrows() {
+  @Test fun dependencyTagTooHighThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaDependencyTagTooHigh::class)
+      parseProtocolSchemaSet(SchemaDependencyTagTooHigh::class)
     }.hasMessageThat().isEqualTo(
-      "Dependency app.cash.redwood.tooling.schema.SchemaParserTest.SchemaTag tag must be in range (0, 2000]: 2001",
+      "app.cash.redwood.layout.RedwoodLayout tag must be 0 for the root or in range (0, 2000] as a dependency: 2001",
     )
 
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaDependencyTagTooLow::class)
+      parseProtocolSchemaSet(SchemaDependencyTagTooLow::class)
     }.hasMessageThat().isEqualTo(
-      "Dependency app.cash.redwood.tooling.schema.SchemaParserTest.SchemaTag tag must be in range (0, 2000]: 0",
+      "app.cash.redwood.layout.RedwoodLayout tag must be 0 for the root or in range (0, 2000] as a dependency: -1",
+    )
+  }
+
+  @Schema(
+    members = [],
+    dependencies = [
+      Dependency(0, RedwoodLayout::class),
+    ],
+  )
+  object SchemaDependencyTagZero
+
+  @Test fun dependencyTagZeroThrows() {
+    assertThrows<IllegalArgumentException> {
+      parseProtocolSchemaSet(SchemaDependencyTagZero::class)
+    }.hasMessageThat().isEqualTo(
+      "Dependency app.cash.redwood.layout.RedwoodLayout tag must not be non-zero",
     )
   }
 
@@ -677,7 +693,7 @@ class SchemaParserTest {
 
   @Test fun schemaDuplicateDependencyTagThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaDuplicateDependencyTag::class)
+      parseProtocolSchemaSet(SchemaDuplicateDependencyTag::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema dependency tags must be unique
@@ -701,7 +717,7 @@ class SchemaParserTest {
 
   @Test fun schemaDuplicateDependencyTypeThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaDuplicateDependencyType::class)
+      parseProtocolSchemaSet(SchemaDuplicateDependencyType::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema contains repeated dependency
@@ -721,7 +737,7 @@ class SchemaParserTest {
 
   @Test fun schemaDependencyHasDependencyThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaDependencyHasDependency::class)
+      parseProtocolSchemaSet(SchemaDependencyHasDependency::class)
     }.hasMessageThat().isEqualTo(
       "Schema dependency example.redwood.ExampleSchema also has its own dependencies. " +
         "For now, only a single level of dependencies is supported.",
@@ -740,7 +756,7 @@ class SchemaParserTest {
 
   @Test fun schemaWidgetDuplicateInDependencyThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(SchemaWidgetDuplicateInDependency::class)
+      parseProtocolSchemaSet(SchemaWidgetDuplicateInDependency::class)
     }.hasMessageThat().isEqualTo(
       """
       |Schema dependency tree contains duplicated widgets
@@ -762,7 +778,7 @@ class SchemaParserTest {
 
   @Test fun `layout modifier must have at least one scope`() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(UnscopedModifierSchema::class)
+      parseProtocolSchemaSet(UnscopedModifierSchema::class)
     }.hasMessageThat().isEqualTo(
       "@LayoutModifier app.cash.redwood.tooling.schema.SchemaParserTest.UnscopedLayoutModifier " +
         "must have at least one scope.",
@@ -784,7 +800,7 @@ class SchemaParserTest {
 
   @Test fun deprecationHiddenThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DeprecationHiddenSchema::class)
+      parseProtocolSchemaSet(DeprecationHiddenSchema::class)
     }.hasMessageThat().isEqualTo(
       "Schema deprecation does not support level HIDDEN: " +
         "val app.cash.redwood.tooling.schema.SchemaParserTest.DeprecationHiddenWidget.a: kotlin.String",
@@ -805,7 +821,7 @@ class SchemaParserTest {
 
   @Test fun deprecationReplaceWithThrows() {
     assertThrows<IllegalArgumentException> {
-      parseProtocolSchema(DeprecationReplaceWithSchema::class)
+      parseProtocolSchemaSet(DeprecationReplaceWithSchema::class)
     }.hasMessageThat().isEqualTo(
       "Schema deprecation does not support replacements: " +
         "class app.cash.redwood.tooling.schema.SchemaParserTest\$DeprecationReplaceWithWidget",