Repository: cashapp/redwood
Pull request #595 (2022-12-06 04:38:40)

Move the FrameClock into the host code
This introduces a new type, AppService, that acts as the base type 'A' for the TreehouseApp type parameter. It is the bridge type that spans the host and downloaded code.

It also introduces an interface, FrameClockService, that receives frames in downloaded code.


Patch:
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffConsumingGeneration.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffConsumingGeneration.kt
index a21b98600..7345461e2 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffConsumingGeneration.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffConsumingGeneration.kt
@@ -197,18 +197,18 @@ internal class DiffConsumingSunspotButton<W : Any>(
   private val serializer_1: KSerializer<Boolean> = json.serializersModule.serializer()
 
   public override fun apply(diff: PropertyDiff, eventSink: EventSink): Unit {
-    when (val tag = diff.tag) {
+    when (diff.tag.value) {
       1 -> delegate.text(json.decodeFromJsonElement(serializer_0, diff.value))
       2 -> delegate.enabled(json.decodeFromJsonElement(serializer_1, diff.value))
       3 -> {
         val onClick: (() -> Unit)? = if (diff.value.jsonPrimitive.boolean) {
-          { eventSink.sendEvent(Event(diff.id, 3)) }
+          { eventSink.sendEvent(Event(diff.id, EventTag(3))) }
         } else {
           null
         }
         delegate.onClick(onClick)
       }
-      else -> mismatchHandler.onUnknownProperty(12, tag)
+      else -> mismatchHandler.onUnknownProperty(12, diff.tag)
     }
   }
 }
@@ -259,7 +259,7 @@ internal fun generateDiffConsumingWidget(schema: Schema, widget: Widget, host: S
               .addModifiers(OVERRIDE)
               .addParameter("diff", Protocol.PropertyDiff)
               .addParameter("eventSink", Protocol.EventSink)
-              .beginControlFlow("when (val tag = diff.tag)")
+              .beginControlFlow("when (diff.tag.value)")
               .apply {
                 for (trait in properties) {
                   when (trait) {
@@ -270,7 +270,7 @@ internal fun generateDiffConsumingWidget(schema: Schema, widget: Widget, host: S
                       }
 
                       addStatement(
-                        "%LU -> widget.%N(json.decodeFromJsonElement(serializer_%L, diff.value))",
+                        "%L -> widget.%N(json.decodeFromJsonElement(serializer_%L, diff.value))",
                         trait.tag,
                         trait.name,
                         serializerId,
@@ -278,7 +278,7 @@ internal fun generateDiffConsumingWidget(schema: Schema, widget: Widget, host: S
                     }
 
                     is Event -> {
-                      beginControlFlow("%LU ->", trait.tag)
+                      beginControlFlow("%L ->", trait.tag)
                       beginControlFlow(
                         "val %N: %T = if (diff.value.%M.%M)",
                         trait.name,
@@ -292,15 +292,17 @@ internal fun generateDiffConsumingWidget(schema: Schema, widget: Widget, host: S
                           nextSerializerId++
                         }
                         addStatement(
-                          "{ eventSink.sendEvent(%T(diff.id, %LU, json.encodeToJsonElement(serializer_%L, it))) }",
+                          "{ eventSink.sendEvent(%T(diff.id, %T(%L), json.encodeToJsonElement(serializer_%L, it))) }",
                           Protocol.Event,
+                          Protocol.EventTag,
                           trait.tag,
                           serializerId,
                         )
                       } else {
                         addStatement(
-                          "{ eventSink.sendEvent(%T(diff.id, %LU)) }",
+                          "{ eventSink.sendEvent(%T(diff.id, %T(%L))) }",
                           Protocol.Event,
+                          Protocol.EventTag,
                           trait.tag,
                         )
                       }
@@ -327,7 +329,7 @@ internal fun generateDiffConsumingWidget(schema: Schema, widget: Widget, host: S
                   )
                 }
               }
-              .addStatement("else -> mismatchHandler.onUnknownProperty(%L, tag)", widget.tag)
+              .addStatement("else -> mismatchHandler.onUnknownProperty(%L, diff.tag)", widget.tag)
               .endControlFlow()
               .build(),
           )
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
index 18c6a8ba3..156e24d42 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/diffProducingGeneration.kt
@@ -141,21 +141,21 @@ internal class DiffProducingSunspotButton(
     }
 
   override fun text(text: String?) {
-    bridge.append(PropertyDiff(this.id, 1, json.encodeToJsonElement(serializer_0, text)))
+    bridge.append(PropertyDiff(this.id, PropertyTag(1), json.encodeToJsonElement(serializer_0, text)))
   }
 
   override fun onClick(onClick: (() -> Unit)?) {
     val onClickSet = onClick != null
     if (onClickSet != (this.onClick != null)) {
-      bridge.append(PropertyDiff(this.id, 3, onClickSet))
+      bridge.append(PropertyDiff(this.id, PropertyTag(3), onClickSet))
     }
     this.onClick = onClick
   }
 
   override fun sendEvent(event: Event) {
-    when (val tag = event.tag) {
+    when (event.tag.value) {
       3 -> onClick?.invoke()
-      else -> mismatchHandler.onUnknownEvent(12, tag)
+      else -> mismatchHandler.onUnknownEvent(12, event.tag)
     }
   }
 }
@@ -222,8 +222,9 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                     .addModifiers(OVERRIDE)
                     .addParameter(trait.name, traitTypeName)
                     .addStatement(
-                      "bridge.append(%T(this.id, %LU, json.encodeToJsonElement(serializer_%L, %N)))",
+                      "bridge.append(%T(this.id, %T(%L), json.encodeToJsonElement(serializer_%L, %N)))",
                       Protocol.PropertyDiff,
+                      Protocol.PropertyTag,
                       trait.tag,
                       serializerId,
                       trait.name,
@@ -245,8 +246,9 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                     .addStatement("val %1NSet = %1N != null", trait.name)
                     .beginControlFlow("if (%1NSet != (this.%1N != null))", trait.name)
                     .addStatement(
-                      "bridge.append(%T(this.id, %LU, %M(%NSet)))",
+                      "bridge.append(%T(this.id, %T(%L), %M(%NSet)))",
                       Protocol.PropertyDiff,
+                      Protocol.PropertyTag,
                       trait.tag,
                       KotlinxSerialization.JsonPrimitive,
                       trait.name,
@@ -275,7 +277,7 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
             FunSpec.builder("sendEvent")
               .addModifiers(OVERRIDE)
               .addParameter("event", Protocol.Event)
-              .beginControlFlow("when (val tag = event.tag)")
+              .beginControlFlow("when (event.tag.value)")
               .apply {
                 for (event in widget.traits.filterIsInstance<Event>()) {
                   val parameterType = event.parameterType?.asTypeName()
@@ -284,17 +286,17 @@ internal fun generateDiffProducingWidget(schema: Schema, widget: Widget, host: S
                       nextSerializerId++
                     }
                     addStatement(
-                      "%LU -> %N?.invoke(json.decodeFromJsonElement(serializer_%L, event.value))",
+                      "%L -> %N?.invoke(json.decodeFromJsonElement(serializer_%L, event.value))",
                       event.tag,
                       event.name,
                       serializerId,
                     )
                   } else {
-                    addStatement("%LU -> %N?.invoke()", event.tag, event.name)
+                    addStatement("%L -> %N?.invoke()", event.tag, event.name)
                   }
                 }
               }
-              .addStatement("else -> mismatchHandler.onUnknownEvent(%L, tag)", widget.tag)
+              .addStatement("else -> mismatchHandler.onUnknownEvent(%L, event.tag)", widget.tag)
               .endControlFlow()
               .build(),
           )
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
index 6586bda86..a737fb440 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/generator/types.kt
@@ -27,9 +27,11 @@ import com.squareup.kotlinpoet.UNIT
 
 internal object Protocol {
   val Event = ClassName("app.cash.redwood.protocol", "Event")
+  val EventTag = ClassName("app.cash.redwood.protocol", "EventTag")
   val EventSink = ClassName("app.cash.redwood.protocol", "EventSink")
   val Id = ClassName("app.cash.redwood.protocol", "Id")
   val PropertyDiff = ClassName("app.cash.redwood.protocol", "PropertyDiff")
+  val PropertyTag = ClassName("app.cash.redwood.protocol", "PropertyTag")
   val LayoutModifiers = ClassName("app.cash.redwood.protocol", "LayoutModifiers")
 }
 
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandler.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandler.kt
index 901eeacee..e0f635faa 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandler.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandler.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.protocol.compose
 
+import app.cash.redwood.protocol.EventTag
 import kotlin.jvm.JvmField
 
 /**
@@ -24,14 +25,14 @@ import kotlin.jvm.JvmField
  */
 public interface ProtocolMismatchHandler {
   /** Handle a request to process an unknown event [tag] for the specified widget [kind]. */
-  public fun onUnknownEvent(kind: Int, tag: UInt)
+  public fun onUnknownEvent(kind: Int, tag: EventTag)
 
   public companion object {
     /** A [ProtocolMismatchHandler] which throws [IllegalArgumentException] for all callbacks. */
     @JvmField
     public val Throwing: ProtocolMismatchHandler = object : ProtocolMismatchHandler {
-      override fun onUnknownEvent(kind: Int, tag: UInt) {
-        throw IllegalArgumentException("Unknown event tag $tag for widget kind $kind")
+      override fun onUnknownEvent(kind: Int, tag: EventTag) {
+        throw IllegalArgumentException("Unknown event tag ${tag.value} for widget kind $kind")
       }
     }
   }
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandler.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandler.kt
index 8aea82aa5..680413870 100644
--- a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandler.kt
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandler.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.protocol.widget
 
+import app.cash.redwood.protocol.PropertyTag
 import kotlin.jvm.JvmField
 
 /**
@@ -33,7 +34,7 @@ public interface ProtocolMismatchHandler {
   public fun onUnknownChildren(kind: Int, tag: UInt)
 
   /** Handle a request to set an unknown property [tag] for the specified widget [kind]. */
-  public fun onUnknownProperty(kind: Int, tag: UInt)
+  public fun onUnknownProperty(kind: Int, tag: PropertyTag)
 
   public companion object {
     /** A [ProtocolMismatchHandler] which throws [IllegalArgumentException] for all callbacks. */
@@ -51,8 +52,8 @@ public interface ProtocolMismatchHandler {
         throw IllegalArgumentException("Unknown children tag $tag for widget kind $kind")
       }
 
-      override fun onUnknownProperty(kind: Int, tag: UInt) {
-        throw IllegalArgumentException("Unknown property tag $tag for widget kind $kind")
+      override fun onUnknownProperty(kind: Int, tag: PropertyTag) {
+        throw IllegalArgumentException("Unknown property tag ${tag.value} for widget kind $kind")
       }
     }
   }
diff --git a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
index c5e332448..3545e184e 100644
--- a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
+++ b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
@@ -15,36 +15,18 @@
  */
 package app.cash.redwood.protocol
 
-import kotlin.jvm.JvmInline
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.json.JsonArray
 import kotlinx.serialization.json.JsonElement
 import kotlinx.serialization.json.JsonNull
 
-public fun interface EventSink {
-  public fun sendEvent(event: Event)
-}
-
-public fun interface DiffSink {
-  public fun sendDiff(diff: Diff)
-}
-
-/** Identifies a widget instance. */
-@JvmInline
-@Serializable
-public value class Id(public val value: Long) {
-  public companion object {
-    public val Root: Id = Id(0L)
-  }
-}
-
 @Serializable
 public data class Event(
   /** Identifier for the widget from which this event originated. */
   val id: Id,
   /** Identifies which event occurred on the widget with [id]. */
-  val tag: UInt,
+  val tag: EventTag,
   val value: JsonElement = JsonNull,
 )
 
@@ -60,7 +42,7 @@ public data class PropertyDiff(
   /** Identifier for the widget whose property has changed. */
   val id: Id,
   /** Identifies which property changed on the widget with [id]. */
-  val tag: UInt,
+  val tag: PropertyTag,
   val value: JsonElement = JsonNull,
 )
 
diff --git a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/sinks.kt b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/sinks.kt
new file mode 100644
index 000000000..29e3a9613
--- /dev/null
+++ b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/sinks.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.protocol
+
+public fun interface EventSink {
+  public fun sendEvent(event: Event)
+}
+
+public fun interface DiffSink {
+  public fun sendDiff(diff: Diff)
+}
diff --git a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/values.kt b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/values.kt
new file mode 100644
index 000000000..ed63b7687
--- /dev/null
+++ b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/values.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.protocol
+
+import kotlin.jvm.JvmInline
+import kotlinx.serialization.Serializable
+
+/** Identifies a widget instance. */
+@JvmInline
+@Serializable
+public value class Id(public val value: Long) {
+  public companion object {
+    public val Root: Id = Id(0L)
+  }
+}
+
+@JvmInline
+@Serializable
+public value class EventTag(public val value: Int)
+
+@JvmInline
+@Serializable
+public value class PropertyTag(public val value: Int)
diff --git a/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt b/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
index 35c3cb84b..8acb988bf 100644
--- a/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
+++ b/redwood-treehouse-composeui/src/main/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.HostConfiguration
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
@@ -30,7 +31,7 @@ import app.cash.redwood.widget.compose.ComposeWidgetChildren
 import kotlinx.coroutines.flow.MutableStateFlow
 
 @Composable
-public fun <A : Any> TreehouseContent(
+public fun <A : AppService> TreehouseContent(
   treehouseApp: TreehouseApp<A>,
   widgetSystem: TreehouseView.WidgetSystem<A>,
   codeListener: CodeListener = CodeListener(),
diff --git a/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeRedwoodLayoutWidgetFactory.kt b/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeRedwoodLayoutWidgetFactory.kt
index 772c888df..2c6595ec4 100644
--- a/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeRedwoodLayoutWidgetFactory.kt
+++ b/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeRedwoodLayoutWidgetFactory.kt
@@ -16,14 +16,15 @@
 package app.cash.redwood.treehouse.lazylayout.composeui
 
 import androidx.compose.runtime.Composable
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
 import app.cash.redwood.treehouse.lazylayout.widget.RedwoodTreehouseLazyLayoutWidgetFactory
 
-public class ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory<W : Any>(
-  private val treehouseApp: TreehouseApp<W>,
-  private val widgetSystem: TreehouseView.WidgetSystem<W>,
+public class ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory<A : AppService>(
+  private val treehouseApp: TreehouseApp<A>,
+  private val widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : RedwoodTreehouseLazyLayoutWidgetFactory<@Composable () -> Unit> {
   override fun LazyColumn(): LazyColumn<@Composable () -> Unit> = ComposeUiLazyColumn(treehouseApp, widgetSystem)
 }
diff --git a/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyColumn.kt b/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyColumn.kt
index 6c2da08ab..4ac73f4f0 100644
--- a/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-composeui/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/composeui/ComposeUiLazyColumn.kt
@@ -27,15 +27,16 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.composeui.TreehouseContent
 import app.cash.redwood.treehouse.lazylayout.api.LazyListIntervalContent
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
 
-internal class ComposeUiLazyColumn<W : Any>(
-  treehouseApp: TreehouseApp<W>,
-  widgetSystem: TreehouseView.WidgetSystem<W>,
+internal class ComposeUiLazyColumn<A : AppService>(
+  treehouseApp: TreehouseApp<A>,
+  widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : LazyColumn<@Composable () -> Unit> {
   private var intervals by mutableStateOf<List<LazyListIntervalContent>>(emptyList())
 
diff --git a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
index cab8d588d..11a02e020 100644
--- a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
@@ -16,6 +16,7 @@
 package app.cash.redwood.treehouse.lazylayout.uiview
 
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseUIKitView
 import app.cash.redwood.treehouse.TreehouseView
@@ -35,7 +36,7 @@ import platform.UIKit.section
 import platform.UIKit.setFrame
 import platform.darwin.NSInteger
 
-internal class UIViewLazyColumn<A : Any>(
+internal class UIViewLazyColumn<A : AppService>(
   treehouseApp: TreehouseApp<A>,
   widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : LazyColumn<UIView> {
@@ -54,7 +55,7 @@ internal class UIViewLazyColumn<A : Any>(
   override val value: UIView get() = root
 }
 
-private class TableViewDataSource<A : Any>(
+private class TableViewDataSource<A : AppService>(
   private val treehouseApp: TreehouseApp<A>,
   private val widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : UITableViewDiffableDataSource() {
@@ -76,7 +77,7 @@ private class TableViewDataSource<A : Any>(
   }
 }
 
-private class CellContent<A : Any>(
+private class CellContent<A : AppService>(
   private val itemProvider: LazyListIntervalContent.Item,
   private val index: Int,
 ) : TreehouseView.Content<A> {
diff --git a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewRedwoodLayoutWidgetFactory.kt b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
index 74807d5a9..9689922ea 100644
--- a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
+++ b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
@@ -15,13 +15,14 @@
  */
 package app.cash.redwood.treehouse.lazylayout.uiview
 
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
 import app.cash.redwood.treehouse.lazylayout.widget.RedwoodTreehouseLazyLayoutWidgetFactory
 import platform.UIKit.UIView
 
-public class UIViewRedwoodTreehouseLazyLayoutWidgetFactory<A : Any>(
+public class UIViewRedwoodTreehouseLazyLayoutWidgetFactory<A : AppService>(
   private var treehouseApp: TreehouseApp<A>,
   private var widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : RedwoodTreehouseLazyLayoutWidgetFactory<UIView> {
diff --git a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
index af7a529b5..ee560e2b6 100644
--- a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
@@ -27,6 +27,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.ListAdapter
 import androidx.recyclerview.widget.RecyclerView
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.TreehouseWidgetView
@@ -38,7 +39,7 @@ private data class LazyContentItem(
   val item: LazyListIntervalContent.Item,
 )
 
-internal class ViewLazyColumn<A : Any>(
+internal class ViewLazyColumn<A : AppService>(
   treehouseApp: TreehouseApp<A>,
   widgetSystem: TreehouseView.WidgetSystem<A>,
   override val value: RecyclerView,
@@ -73,7 +74,7 @@ internal class ViewLazyColumn<A : Any>(
     )
   }
 
-  private class LazyContentItemListAdapter<A : Any>(
+  private class LazyContentItemListAdapter<A : AppService>(
     private val treehouseApp: TreehouseApp<A>,
     private val widgetSystem: TreehouseView.WidgetSystem<A>,
     private val contentHeight: Int,
@@ -93,7 +94,7 @@ internal class ViewLazyColumn<A : Any>(
     }
   }
 
-  private class ViewHolder<A : Any>(
+  private class ViewHolder<A : AppService>(
     container: FrameLayout,
     treehouseApp: TreehouseApp<A>,
     widgetSystem: TreehouseView.WidgetSystem<A>,
diff --git a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewRedwoodTreehouseLazyLayoutWidgetFactory.kt b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewRedwoodTreehouseLazyLayoutWidgetFactory.kt
index 4bfa3c419..8a3503e62 100644
--- a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewRedwoodTreehouseLazyLayoutWidgetFactory.kt
+++ b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewRedwoodTreehouseLazyLayoutWidgetFactory.kt
@@ -18,12 +18,13 @@ package app.cash.redwood.treehouse.lazylayout.view
 import android.content.Context
 import android.view.View
 import androidx.recyclerview.widget.RecyclerView
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
 import app.cash.redwood.treehouse.lazylayout.widget.RedwoodTreehouseLazyLayoutWidgetFactory
 
-public class ViewRedwoodTreehouseLazyLayoutWidgetFactory<A : Any>(
+public class ViewRedwoodTreehouseLazyLayoutWidgetFactory<A : AppService>(
   private val context: Context,
   private val treehouseApp: TreehouseApp<A>,
   private val widgetSystem: TreehouseView.WidgetSystem<A>,
diff --git a/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt b/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
index be91f101e..d72841538 100644
--- a/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
+++ b/redwood-treehouse/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
@@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 
 @SuppressLint("ViewConstructor")
-public class TreehouseWidgetView<A : Any>(
+public class TreehouseWidgetView<A : AppService>(
   context: Context,
   override val widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : FrameLayout(context), TreehouseView<A> {
diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppService.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppService.kt
new file mode 100644
index 000000000..def3b73f5
--- /dev/null
+++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppService.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+import app.cash.zipline.ZiplineService
+
+/**
+ * Base interface for Treehouse applications. Each application should extend this interface to
+ * declare APIs that are declared by downloaded code and called from host code.
+ *
+ * Note that due to a Zipline limitation it's necessary for implementing classes to declare a direct
+ * dependency on [ZiplineService]. https://github.com/cashapp/zipline/issues/765
+ */
+public interface AppService : ZiplineService {
+  public val frameClockService: FrameClockService
+    get() = StandardFrameClockService
+}
diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/FrameClockService.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/FrameClockService.kt
new file mode 100644
index 000000000..a6bdb5066
--- /dev/null
+++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/FrameClockService.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+import app.cash.zipline.ZiplineService
+
+public interface FrameClockService : ZiplineService {
+  public fun sendFrame(timeNanos: Long)
+}
+
+public expect val StandardFrameClockService: FrameClockService
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventListener.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventListener.kt
index f08d126d4..636f9713e 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventListener.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventListener.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.treehouse
 
+import app.cash.redwood.protocol.PropertyTag
 import app.cash.zipline.Call
 import app.cash.zipline.CallResult
 import app.cash.zipline.ZiplineService
@@ -79,7 +80,7 @@ public abstract class EventListener {
   public open fun onUnknownProperty(
     app: TreehouseApp<*>,
     kind: Int,
-    tag: UInt,
+    tag: PropertyTag,
   ) {
   }
 
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt
index 4977036ee..905fd06a7 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.treehouse
 
+import app.cash.redwood.protocol.PropertyTag
 import app.cash.redwood.protocol.widget.ProtocolMismatchHandler
 import app.cash.zipline.Call
 import app.cash.zipline.CallResult
@@ -131,7 +132,7 @@ internal class EventPublisher(
       listener.onUnknownChildren(app, kind, tag)
     }
 
-    override fun onUnknownProperty(kind: Int, tag: UInt) {
+    override fun onUnknownProperty(kind: Int, tag: PropertyTag) {
       listener.onUnknownProperty(app, kind, tag)
     }
   }
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceHost.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceHost.kt
new file mode 100644
index 000000000..44aacb6ee
--- /dev/null
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceHost.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+public actual val StandardFrameClockService: FrameClockService = object : FrameClockService {
+  override fun sendFrame(timeNanos: Long) {
+    error("unexpected call to sendFrame() on the host")
+  }
+}
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index 19a804be8..b79d0c173 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -25,6 +25,7 @@ import app.cash.zipline.Zipline
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.job
 import kotlinx.coroutines.launch
@@ -34,10 +35,10 @@ import kotlinx.serialization.modules.SerializersModule
 /**
  * This class binds downloaded code to on-screen views.
  *
- * It updates the binding when the views change in [onContentChanged], and when new code is
- * available in [onCodeChanged].
+ * It updates the binding when the views change via [TreehouseView.OnStateChangeListener], and when
+ * new code is available in [onCodeChanged].
  */
-public class TreehouseApp<A : Any> internal constructor(
+public class TreehouseApp<A : AppService> internal constructor(
   private val scope: CoroutineScope,
   public val spec: Spec<A>,
   public val dispatchers: TreehouseDispatchers,
@@ -87,6 +88,22 @@ public class TreehouseApp<A : Any> internal constructor(
     // This job lets us cancel the Android Treehouse job without canceling its sibling jobs.
     val supervisorJob = SupervisorJob(scope.coroutineContext.job)
     val cancelableScope = CoroutineScope(supervisorJob + dispatchers.zipline)
+
+    cancelableScope.launch(dispatchers.zipline) {
+      val clockService = context.frameClockService
+      coroutineContext.job.invokeOnCompletion {
+        clockService.close()
+      }
+      val ticksPerSecond = 60
+      var now = 0L
+      val delayNanos = 1_000_000_000L / ticksPerSecond
+      while (true) {
+        clockService.sendFrame(now)
+        delay(delayNanos / 1_000_000)
+        now += delayNanos
+      }
+    }
+
     cancelableScope.launch(dispatchers.ui) {
       val previous = ziplineSession
 
@@ -285,7 +302,7 @@ public class TreehouseApp<A : Any> internal constructor(
   /**
    * Configuration and code to launch a Treehouse application.
    */
-  public abstract class Spec<A : Any> {
+  public abstract class Spec<A : AppService> {
     public abstract val name: String
     public abstract val manifestUrl: Flow<String>
 
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseLauncher.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseLauncher.kt
index f80bd3e95..5dc15ab33 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseLauncher.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseLauncher.kt
@@ -69,7 +69,7 @@ public class TreehouseLauncher internal constructor(
     )
   }
 
-  public fun <A : Any> launch(
+  public fun <A : AppService> launch(
     scope: CoroutineScope,
     spec: TreehouseApp.Spec<A>,
   ): TreehouseApp<A> {
diff --git a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
index 23030251f..ca5395b29 100644
--- a/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
+++ b/redwood-treehouse/src/hostMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
@@ -21,7 +21,7 @@ import app.cash.redwood.widget.Widget
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.serialization.json.Json
 
-public interface TreehouseView<A : Any> {
+public interface TreehouseView<A : AppService> {
   /** This is the actual content, or null if not attached to the screen. */
   public val boundContent: Content<A>?
   public val children: Widget.Children<*>
@@ -33,18 +33,18 @@ public interface TreehouseView<A : Any> {
   /** Invoked when new code is loaded. This should at minimum clear all [children]. */
   public fun reset()
 
-  public fun interface OnStateChangeListener<A : Any> {
+  public fun interface OnStateChangeListener<A : AppService> {
     /**
      * Called when [TreehouseView.boundContent] has changed.
      */
     public fun onStateChanged(view: TreehouseView<A>)
   }
 
-  public fun interface Content<A : Any> {
+  public fun interface Content<A : AppService> {
     public fun get(app: A): ZiplineTreehouseUi
   }
 
-  public fun interface WidgetSystem<A : Any> {
+  public fun interface WidgetSystem<A : AppService> {
     /** Returns a widget factory for encoding and decoding changes to the contents of [view]. */
     public fun widgetFactory(
       app: TreehouseApp<A>,
diff --git a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
index 6e213659f..b21ad5bb4 100644
--- a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
+++ b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
@@ -33,7 +33,7 @@ import platform.UIKit.setFrame
 import platform.UIKit.subviews
 import platform.UIKit.superview
 
-public class TreehouseUIKitView<A : Any>(
+public class TreehouseUIKitView<A : AppService>(
   override val widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : TreehouseView<A> {
   @Deprecated(
diff --git a/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJs.kt b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJs.kt
new file mode 100644
index 000000000..c5a3eaac4
--- /dev/null
+++ b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJs.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+import androidx.compose.runtime.BroadcastFrameClock
+
+internal val StandardFrameClock = BroadcastFrameClock()
+
+public actual val StandardFrameClockService: FrameClockService = object : FrameClockService {
+  override fun sendFrame(timeNanos: Long) {
+    StandardFrameClock.sendFrame(timeNanos)
+  }
+}
diff --git a/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
index e34f9e0e5..87f085434 100644
--- a/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
+++ b/redwood-treehouse/src/jsMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt
@@ -15,7 +15,6 @@
  */
 package app.cash.redwood.treehouse
 
-import androidx.compose.runtime.BroadcastFrameClock
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
@@ -26,8 +25,6 @@ import app.cash.redwood.protocol.compose.ProtocolRedwoodComposition
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.plus
 
 /**
@@ -56,7 +53,7 @@ private class RedwoodZiplineTreehouseUi(
     diffSinkToClose = diffSink
 
     val composition = ProtocolRedwoodComposition(
-      scope = scope + frameClock,
+      scope = scope + StandardFrameClock,
       factory = factory,
       widgetVersion = widgetVersion,
       diffSink = diffSink,
@@ -81,22 +78,3 @@ private class RedwoodZiplineTreehouseUi(
 
 @OptIn(DelicateCoroutinesApi::class)
 private val scope: CoroutineScope = GlobalScope
-private val frameClock: BroadcastFrameClock by lazy { newFrameClock(scope) }
-
-// TODO(jwilson): replace this with a native frame clock.
-private fun newFrameClock(
-  coroutineScope: CoroutineScope,
-  ticksPerSecond: Long = 60,
-): BroadcastFrameClock {
-  val result = BroadcastFrameClock()
-  coroutineScope.launch {
-    var now = 0L
-    val delayNanos = 1_000_000_000L / ticksPerSecond
-    while (true) {
-      result.sendFrame(now)
-      delay(delayNanos / 1_000_000)
-      now += delayNanos
-    }
-  }
-  return result
-}
diff --git a/redwood-treehouse/src/jvmMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJvm.kt b/redwood-treehouse/src/jvmMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJvm.kt
new file mode 100644
index 000000000..a893e52f4
--- /dev/null
+++ b/redwood-treehouse/src/jvmMain/kotlin/app/cash/redwood/treehouse/FrameClockServiceJvm.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.treehouse
+
+// TODO(jwilson): deduplicate with identical symbol in hostMain
+public actual val StandardFrameClockService: FrameClockService = object : FrameClockService {
+  override fun sendFrame(timeNanos: Long) {
+    error("unexpected call to sendFrame() on the host")
+  }
+}
diff --git a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
index 9cebd08ef..aa2990f5c 100644
--- a/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
+++ b/samples/emoji-search/android-composeui/src/main/java/app/cash/zipline/samples/emojisearch/composeui/AndroidEmojiSearchWidgetFactory.kt
@@ -17,15 +17,16 @@ package app.cash.zipline.samples.emojisearch.composeui
 
 import androidx.compose.runtime.Composable
 import app.cash.redwood.layout.composeui.ComposeRedwoodLayoutWidgetFactory
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.lazylayout.composeui.ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.lazylayout.widget.RedwoodTreehouseLazyLayoutWidgetFactory
 import example.schema.widget.EmojiSearchWidgetFactory
 
-class AndroidEmojiSearchWidgetFactory<W : Any>(
-  private val treehouseApp: TreehouseApp<W>,
-  widgetSystem: TreehouseView.WidgetSystem<W>,
+class AndroidEmojiSearchWidgetFactory<A : AppService>(
+  private val treehouseApp: TreehouseApp<A>,
+  widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : EmojiSearchWidgetFactory<@Composable () -> Unit> {
   override val RedwoodLayout = ComposeRedwoodLayoutWidgetFactory()
   override val RedwoodTreehouseLazyLayout = ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory(treehouseApp, widgetSystem)
diff --git a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
index f5cf5867a..367f41d39 100644
--- a/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
+++ b/samples/emoji-search/android-views/src/main/java/app/cash/zipline/samples/emojisearch/views/AndroidViewEmojiSearchWidgetFactory.kt
@@ -17,20 +17,19 @@ package app.cash.zipline.samples.emojisearch.views
 
 import android.content.Context
 import android.view.View
-import android.widget.EditText
 import android.widget.ImageView
 import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
 import app.cash.redwood.layout.view.ViewRedwoodLayoutWidgetFactory
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.lazylayout.view.ViewRedwoodTreehouseLazyLayoutWidgetFactory
 import example.schema.widget.EmojiSearchWidgetFactory
 
-class AndroidViewEmojiSearchWidgetFactory<W : Any>(
+class AndroidViewEmojiSearchWidgetFactory<A : AppService>(
   private val context: Context,
-  private val treehouseApp: TreehouseApp<W>,
-  widgetSystem: TreehouseView.WidgetSystem<W>,
+  private val treehouseApp: TreehouseApp<A>,
+  widgetSystem: TreehouseView.WidgetSystem<A>,
 ) : EmojiSearchWidgetFactory<View> {
   override val RedwoodLayout = ViewRedwoodLayoutWidgetFactory(context)
   override val RedwoodTreehouseLazyLayout = ViewRedwoodTreehouseLazyLayoutWidgetFactory(context, treehouseApp, widgetSystem)
diff --git a/samples/emoji-search/ios/app/EmojiSearchApp/EmojiSearchViewController.swift b/samples/emoji-search/ios/app/EmojiSearchApp/EmojiSearchViewController.swift
index dbb183e0f..357e837d5 100644
--- a/samples/emoji-search/ios/app/EmojiSearchApp/EmojiSearchViewController.swift
+++ b/samples/emoji-search/ios/app/EmojiSearchApp/EmojiSearchViewController.swift
@@ -45,14 +45,14 @@ class EmojiSearchViewController : UIViewController {
 }
 
 class EmojiSearchContent : Redwood_treehouseTreehouseViewContent {
-    func get(app: Any) -> Redwood_treehouseZiplineTreehouseUi {
+    func get(app: Redwood_treehouseAppService) -> Redwood_treehouseZiplineTreehouseUi {
         let treehouesUi = (app as! PresentersEmojiSearchPresenter)
         return treehouesUi.launch()
     }
 }
 
 class EmojiSearchWidgetSystem : Redwood_treehouseTreehouseViewWidgetSystem {
-    func widgetFactory(app: Redwood_treehouseTreehouseApp<AnyObject>, json: Kotlinx_serialization_jsonJson, protocolMismatchHandler: Redwood_protocol_widgetProtocolMismatchHandler) -> Redwood_protocol_widgetDiffConsumingNodeFactory {
+    func widgetFactory(app: Redwood_treehouseTreehouseApp<Redwood_treehouseAppService>, json: Kotlinx_serialization_jsonJson, protocolMismatchHandler: Redwood_protocol_widgetProtocolMismatchHandler) -> Redwood_protocol_widgetDiffConsumingNodeFactory {
         return ExposedKt.diffConsumingEmojiSearchWidgetFactory(
             delegate: IosEmojiSearchWidgetFactory(treehouseApp: app, widgetSystem: self),
             json: json,
diff --git a/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearchPresenter.kt b/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearchPresenter.kt
index b3f7527f5..53e8a8c28 100644
--- a/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearchPresenter.kt
+++ b/samples/emoji-search/presenters/src/commonMain/kotlin/app/cash/zipline/samples/emojisearch/EmojiSearchPresenter.kt
@@ -15,9 +15,10 @@
  */
 package app.cash.zipline.samples.emojisearch
 
+import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.ZiplineTreehouseUi
 import app.cash.zipline.ZiplineService
 
-interface EmojiSearchPresenter : ZiplineService {
+interface EmojiSearchPresenter : AppService, ZiplineService {
   fun launch(): ZiplineTreehouseUi
 }


Test patch:
diff --git a/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt b/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
index b07dcc6d1..0e1d768a3 100644
--- a/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
+++ b/redwood-cli/src/test/kotlin/app/cash/redwood/generator/DiffProducingGenerationTest.kt
@@ -43,7 +43,7 @@ class DiffProducingGenerationTest {
     assertThat(fileSpec.toString()).contains(
       """
       |  public override fun id(id: String): Unit {
-      |    bridge.append(PropertyDiff(this.id, 2U, json.encodeToJsonElement(serializer_0, id)))
+      |    bridge.append(PropertyDiff(this.id, PropertyTag(2), json.encodeToJsonElement(serializer_0, id)))
       |  }
       |
       """.trimMargin(),
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
index 842c4de22..0aa04a80d 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffProducingWidgetFactoryTest.kt
@@ -18,9 +18,11 @@ package app.cash.redwood.protocol.compose
 import app.cash.redwood.LayoutModifier
 import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.Event
+import app.cash.redwood.protocol.EventTag
 import app.cash.redwood.protocol.Id
 import app.cash.redwood.protocol.LayoutModifiers
 import app.cash.redwood.protocol.PropertyDiff
+import app.cash.redwood.protocol.PropertyTag
 import example.redwood.compose.DiffProducingExampleSchemaWidgetFactory
 import example.redwood.compose.TestScope
 import kotlin.test.Test
@@ -49,7 +51,7 @@ class DiffProducingWidgetFactoryTest {
 
     val expected = Diff(
       propertyDiffs = listOf(
-        PropertyDiff(Id(1), 2U, JsonPrimitive("PT10S")),
+        PropertyDiff(Id(1), PropertyTag(2), JsonPrimitive("PT10S")),
       ),
     )
     assertEquals(expected, bridge.createDiffOrNull())
@@ -143,7 +145,7 @@ class DiffProducingWidgetFactoryTest {
       argument = it
     }
 
-    diffProducingWidget.sendEvent(Event(Id(1), 4U, JsonPrimitive("PT10S")))
+    diffProducingWidget.sendEvent(Event(Id(1), EventTag(4), JsonPrimitive("PT10S")))
 
     assertEquals(10.seconds, argument)
   }
@@ -152,7 +154,7 @@ class DiffProducingWidgetFactoryTest {
     val factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge())
     val button = factory.Button() as DiffProducingWidget
 
-    val event = Event(Id(1), 3456543U)
+    val event = Event(Id(1), EventTag(3456543))
     val t = assertFailsWith<IllegalArgumentException> {
       button.sendEvent(event)
     }
@@ -165,7 +167,7 @@ class DiffProducingWidgetFactoryTest {
     val factory = DiffProducingExampleSchemaWidgetFactory(ProtocolBridge(), mismatchHandler = handler)
     val button = factory.Button() as DiffProducingWidget
 
-    button.sendEvent(Event(Id(1), 3456543U))
+    button.sendEvent(Event(Id(1), EventTag(3456543)))
 
     assertEquals("Unknown event 3456543 for 4", handler.events.single())
   }
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandlerTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandlerTest.kt
index 929d3fdec..62d39253a 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandlerTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolMismatchHandlerTest.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.protocol.compose
 
+import app.cash.redwood.protocol.EventTag
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
@@ -22,7 +23,7 @@ import kotlin.test.assertFailsWith
 class ProtocolMismatchHandlerTest {
   @Test fun throwingUnknownEvent() {
     val t = assertFailsWith<IllegalArgumentException> {
-      ProtocolMismatchHandler.Throwing.onUnknownEvent(1, 2U)
+      ProtocolMismatchHandler.Throwing.onUnknownEvent(1, EventTag(2))
     }
     assertEquals("Unknown event tag 2 for widget kind 1", t.message)
   }
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
index e377bc0d1..610ccc11f 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
@@ -24,9 +24,11 @@ import app.cash.redwood.protocol.ChildrenDiff
 import app.cash.redwood.protocol.ChildrenDiff.Companion.RootChildrenTag
 import app.cash.redwood.protocol.Diff
 import app.cash.redwood.protocol.Event
+import app.cash.redwood.protocol.EventTag
 import app.cash.redwood.protocol.Id
 import app.cash.redwood.protocol.LayoutModifiers
 import app.cash.redwood.protocol.PropertyDiff
+import app.cash.redwood.protocol.PropertyTag
 import example.redwood.compose.Button
 import example.redwood.compose.DiffProducingExampleSchemaWidgetFactory
 import example.redwood.compose.Row
@@ -96,8 +98,8 @@ class ProtocolTest {
           LayoutModifiers(Id(4), JsonArray(listOf())),
         ),
         propertyDiffs = listOf(
-          PropertyDiff(Id(2), 1U /* text */, JsonPrimitive("hey")),
-          PropertyDiff(Id(4), 1U /* text */, JsonPrimitive("hello")),
+          PropertyDiff(Id(2), PropertyTag(1) /* text */, JsonPrimitive("hey")),
+          PropertyDiff(Id(4), PropertyTag(1) /* text */, JsonPrimitive("hello")),
         ),
       ),
       diffs.removeFirst(),
@@ -141,37 +143,37 @@ class ProtocolTest {
           LayoutModifiers(Id(1), JsonArray(listOf())),
         ),
         propertyDiffs = listOf(
-          PropertyDiff(Id(1), 1U /* text */, JsonPrimitive("state: 0")),
-          PropertyDiff(Id(1), 2U /* onClick */, JsonPrimitive(true)),
+          PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 0")),
+          PropertyDiff(Id(1), PropertyTag(2) /* onClick */, JsonPrimitive(true)),
         ),
       ),
       diffs.removeFirst(),
     )
 
     // Invoke the onClick lambda to move the state from 0 to 1.
-    bridge.sendEvent(Event(Id(1), 2U))
+    bridge.sendEvent(Event(Id(1), EventTag(2)))
     yield() // Allow state change to be handled.
 
     clock.awaitFrame()
     assertEquals(
       Diff(
         propertyDiffs = listOf(
-          PropertyDiff(Id(1), 1U /* text */, JsonPrimitive("state: 1")),
+          PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 1")),
         ),
       ),
       diffs.removeFirst(),
     )
 
     // Invoke the onClick lambda to move the state from 1 to 2.
-    bridge.sendEvent(Event(Id(1), 2U))
+    bridge.sendEvent(Event(Id(1), EventTag(2)))
     yield() // Allow state change to be handled.
 
     clock.awaitFrame()
     assertEquals(
       Diff(
         propertyDiffs = listOf(
-          PropertyDiff(Id(1), 1U /* text */, JsonPrimitive("state: 2")),
-          PropertyDiff(Id(1), 2U /* text */, JsonPrimitive(false)),
+          PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 2")),
+          PropertyDiff(Id(1), PropertyTag(2) /* text */, JsonPrimitive(false)),
         ),
       ),
       diffs.removeFirst(),
@@ -185,7 +187,7 @@ class ProtocolTest {
     assertEquals(
       Diff(
         propertyDiffs = listOf(
-          PropertyDiff(Id(1), 1U /* text */, JsonPrimitive("state: 3")),
+          PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 3")),
         ),
       ),
       diffs.removeFirst(),
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingProtocolMismatchHandler.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingProtocolMismatchHandler.kt
index 8740c5ecc..a5eaef5ab 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingProtocolMismatchHandler.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/RecordingProtocolMismatchHandler.kt
@@ -15,10 +15,12 @@
  */
 package app.cash.redwood.protocol.compose
 
+import app.cash.redwood.protocol.EventTag
+
 class RecordingProtocolMismatchHandler : ProtocolMismatchHandler {
   val events = mutableListOf<String>()
 
-  override fun onUnknownEvent(kind: Int, tag: UInt) {
-    events += "Unknown event $tag for $kind"
+  override fun onUnknownEvent(kind: Int, tag: EventTag) {
+    events += "Unknown event ${tag.value} for $kind"
   }
 }
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingWidgetFactoryTest.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingWidgetFactoryTest.kt
index 0d3ac43a0..8cb6c8e65 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingWidgetFactoryTest.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/DiffConsumingWidgetFactoryTest.kt
@@ -18,8 +18,10 @@ package app.cash.redwood.protocol.widget
 import app.cash.redwood.LayoutModifier
 import app.cash.redwood.protocol.Event
 import app.cash.redwood.protocol.EventSink
+import app.cash.redwood.protocol.EventTag
 import app.cash.redwood.protocol.Id
 import app.cash.redwood.protocol.PropertyDiff
+import app.cash.redwood.protocol.PropertyTag
 import example.redwood.compose.TestScope
 import example.redwood.widget.DiffConsumingExampleSchemaWidgetFactory
 import example.redwood.widget.TextInput
@@ -229,7 +231,7 @@ class DiffConsumingWidgetFactoryTest {
     val textInput = factory.create(Id.Root, ThrowingWidgetChildren(), 5)!!
 
     val throwingEventSink = EventSink { error(it) }
-    textInput.apply(PropertyDiff(Id(1), 2U, JsonPrimitive("PT10S")), throwingEventSink)
+    textInput.apply(PropertyDiff(Id(1), PropertyTag(2), JsonPrimitive("PT10S")), throwingEventSink)
 
     assertEquals(10.seconds, recordingTextInput.customType)
   }
@@ -238,7 +240,7 @@ class DiffConsumingWidgetFactoryTest {
     val factory = DiffConsumingExampleSchemaWidgetFactory(EmptyExampleSchemaWidgetFactory())
     val button = factory.create(Id.Root, ThrowingWidgetChildren(), 4)!!
 
-    val diff = PropertyDiff(Id(1), 345432U)
+    val diff = PropertyDiff(Id(1), PropertyTag(345432))
     val eventSink = EventSink { throw UnsupportedOperationException() }
     val t = assertFailsWith<IllegalArgumentException> {
       button.apply(diff, eventSink)
@@ -254,7 +256,7 @@ class DiffConsumingWidgetFactoryTest {
     )
     val button = factory.create(Id.Root, ThrowingWidgetChildren(), 4)!!
 
-    button.apply(PropertyDiff(Id(1), 345432U)) { throw UnsupportedOperationException() }
+    button.apply(PropertyDiff(Id(1), PropertyTag(345432))) { throw UnsupportedOperationException() }
 
     assertEquals("Unknown property 345432 for 4", handler.events.single())
   }
@@ -275,11 +277,11 @@ class DiffConsumingWidgetFactoryTest {
     val textInput = factory.create(Id.Root, ThrowingWidgetChildren(), 5)!!
 
     val eventSink = RecordingEventSink()
-    textInput.apply(PropertyDiff(Id(1), 4U, JsonPrimitive(true)), eventSink)
+    textInput.apply(PropertyDiff(Id(1), PropertyTag(4), JsonPrimitive(true)), eventSink)
 
     recordingTextInput.onChangeCustomType!!.invoke(10.seconds)
 
-    assertEquals(Event(Id(1), 4U, JsonPrimitive("PT10S")), eventSink.events.single())
+    assertEquals(Event(Id(1), EventTag(4), JsonPrimitive("PT10S")), eventSink.events.single())
   }
 
   class RecordingTextInput : TextInput<Nothing> {
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandlerTest.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandlerTest.kt
index 77bafc08d..faa52b1b2 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandlerTest.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/ProtocolMismatchHandlerTest.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.protocol.widget
 
+import app.cash.redwood.protocol.PropertyTag
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
@@ -43,7 +44,7 @@ class ProtocolMismatchHandlerTest {
 
   @Test fun throwingUnknownProperty() {
     val t = assertFailsWith<IllegalArgumentException> {
-      ProtocolMismatchHandler.Throwing.onUnknownProperty(1, 2U)
+      ProtocolMismatchHandler.Throwing.onUnknownProperty(1, PropertyTag(2))
     }
     assertEquals("Unknown property tag 2 for widget kind 1", t.message)
   }
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/RecordingProtocolMismatchHandler.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/RecordingProtocolMismatchHandler.kt
index c83211ca9..e7e177c18 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/RecordingProtocolMismatchHandler.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/RecordingProtocolMismatchHandler.kt
@@ -15,6 +15,8 @@
  */
 package app.cash.redwood.protocol.widget
 
+import app.cash.redwood.protocol.PropertyTag
+
 class RecordingProtocolMismatchHandler : ProtocolMismatchHandler {
   val events = mutableListOf<String>()
 
@@ -30,7 +32,7 @@ class RecordingProtocolMismatchHandler : ProtocolMismatchHandler {
     events += "Unknown children $tag for $kind"
   }
 
-  override fun onUnknownProperty(kind: Int, tag: UInt) {
-    events += "Unknown property $tag for $kind"
+  override fun onUnknownProperty(kind: Int, tag: PropertyTag) {
+    events += "Unknown property ${tag.value} for $kind"
   }
 }
diff --git a/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt b/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
index d21f8ff74..7bcf7e3b9 100644
--- a/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
+++ b/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
@@ -30,13 +30,13 @@ class ProtocolTest {
   }
 
   @Test fun eventNonNullValue() {
-    val model = Event(Id(1), 2U, JsonPrimitive("Hello"))
+    val model = Event(Id(1), EventTag(2), JsonPrimitive("Hello"))
     val json = """{"id":1,"tag":2,"value":"Hello"}"""
     assertJsonRoundtrip(Event.serializer(), model, json)
   }
 
   @Test fun eventNullValue() {
-    val model = Event(Id(1), 2U)
+    val model = Event(Id(1), EventTag(2))
     val json = """{"id":1,"tag":2}"""
     assertJsonRoundtrip(Event.serializer(), model, json)
   }
@@ -62,8 +62,8 @@ class ProtocolTest {
         ),
       ),
       propertyDiffs = listOf(
-        PropertyDiff(Id(1), 2U, JsonPrimitive("Hello")),
-        PropertyDiff(Id(1), 2U, JsonNull),
+        PropertyDiff(Id(1), PropertyTag(2), JsonPrimitive("Hello")),
+        PropertyDiff(Id(1), PropertyTag(2), JsonNull),
       ),
     )
     val json = "" +