From c69ab169abafd17fc6d1510530612e85b2c7ba10 Mon Sep 17 00:00:00 2001 From: Jake Wharton Date: Tue, 30 Jul 2024 17:11:29 -0400 Subject: [PATCH] Defer serializing event args to JSON Until we're on the Zipline thread instead of the UI thread. --- CHANGELOG.md | 2 +- redwood-protocol-host/build.gradle | 1 + .../protocol/host/HostProtocolAdapter.kt | 5 +- .../redwood/protocol/host/ProtocolNode.kt | 3 +- .../app/cash/redwood/protocol/host/UiEvent.kt | 59 ++++++++++++ .../protocol/host/ChildrenNodeIndexTest.kt | 3 +- .../protocol/host/ProtocolFactoryTest.kt | 9 +- ...ngEventSink.kt => RecordingUiEventSink.kt} | 11 +-- .../tooling/codegen/protocolHostGeneration.kt | 90 ++++++++++--------- .../app/cash/redwood/tooling/codegen/types.kt | 4 +- .../redwood/treehouse/ChangeListRenderer.kt | 4 +- .../redwood/treehouse/TreehouseAppContent.kt | 14 ++- .../redwood/treehouse/FakeProtocolNode.kt | 8 +- 13 files changed, 142 insertions(+), 71 deletions(-) create mode 100644 redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/UiEvent.kt rename redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/{RecordingEventSink.kt => RecordingUiEventSink.kt} (74%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5fbd1750..56b1f68efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ New: - Source-based schema parser is now the default. Can be disabled in your schema module with `redwood { useFir = false }`. Changed: -- Nothing yet! +- In Treehouse, events from the UI are now serialized on a background thread. This means that there is both a delay and a thread change between when a UI binding sends an event and when that object is converted to JSON. All arguments to events must not be mutable and support property reads on any thread. Best practice is for all event arguments to be completely immutable. Fixed: - Nothing yet! diff --git a/redwood-protocol-host/build.gradle b/redwood-protocol-host/build.gradle index e23a94e89b..4615fde7ec 100644 --- a/redwood-protocol-host/build.gradle +++ b/redwood-protocol-host/build.gradle @@ -6,6 +6,7 @@ redwoodBuild { } apply plugin: 'com.github.gmazzo.buildconfig' +apply plugin: 'dev.drewhamilton.poko' kotlin { sourceSets { diff --git a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt index e7a01e1434..c74dba4397 100644 --- a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt +++ b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt @@ -25,7 +25,6 @@ import app.cash.redwood.protocol.ChildrenChange.Move import app.cash.redwood.protocol.ChildrenChange.Remove import app.cash.redwood.protocol.ChildrenTag import app.cash.redwood.protocol.Create -import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.ModifierChange import app.cash.redwood.protocol.PropertyChange @@ -50,7 +49,7 @@ public class HostProtocolAdapter( guestVersion: RedwoodVersion, container: Widget.Children, factory: ProtocolFactory, - private val eventSink: EventSink, + private val eventSink: UiEventSink, ) : ChangesSink { private val factory = requireNotNull(factory as? GeneratedProtocolFactory) { "Factory ${factory::class} was not generated by Redwood or is out of date" @@ -378,7 +377,7 @@ private class RootProtocolNode( Widget { private val children = ProtocolChildren(children) - override fun apply(change: PropertyChange, eventSink: EventSink) { + override fun apply(change: PropertyChange, eventSink: UiEventSink) { throw AssertionError("unexpected: $change") } diff --git a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt index 6451fc2c33..66a2e573c6 100644 --- a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt +++ b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt @@ -18,7 +18,6 @@ package app.cash.redwood.protocol.host import app.cash.redwood.Modifier import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.protocol.ChildrenTag -import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.PropertyChange import app.cash.redwood.protocol.WidgetTag @@ -48,7 +47,7 @@ public abstract class ProtocolNode( /** Assigned when the node is added to the pool. */ internal var shapeHash = 0L - public abstract fun apply(change: PropertyChange, eventSink: EventSink) + public abstract fun apply(change: PropertyChange, eventSink: UiEventSink) public fun updateModifier(modifier: Modifier) { widget.modifier = modifier diff --git a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/UiEvent.kt b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/UiEvent.kt new file mode 100644 index 0000000000..0399386d0c --- /dev/null +++ b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/UiEvent.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 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.host + +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 dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json + +/** + * A version of [Event] whose arguments have not yet been serialized to JSON and is thus + * cheap to create on the UI thread. + */ +@Poko +public class UiEvent( + public val id: Id, + public val tag: EventTag, + public val args: List = emptyList(), + public val serializationStrategies: List> = emptyList(), +) { + init { + check(args.size == serializationStrategies.size) { + "Properties 'args' and 'serializationStrategies' must have the same size. " + + "Found ${args.size} and ${serializationStrategies.size}" + } + } + + /** Serialize [args] into a JSON model using [serializationStrategies] into an [Event]. */ + public fun toProtocol(json: Json): Event { + return Event( + id = id, + tag = tag, + args = List(args.size) { + json.encodeToJsonElement(serializationStrategies[it], args[it]) + } + ) + } +} + +/** A version of [EventSink] which consumes [UiEvent]s. */ +public fun interface UiEventSink { + public fun sendEvent(uiEvent: UiEvent) +} diff --git a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt index 88ee0146f6..067e86b0a0 100644 --- a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt +++ b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt @@ -18,7 +18,6 @@ package app.cash.redwood.protocol.host import app.cash.redwood.Modifier import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.protocol.ChildrenTag -import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.PropertyChange import app.cash.redwood.protocol.WidgetTag @@ -128,7 +127,7 @@ class ChildrenNodeIndexTest { @OptIn(RedwoodCodegenApi::class) private class WidgetNode(override val widget: StringWidget) : ProtocolNode(Id(1), WidgetTag(1)) { - override fun apply(change: PropertyChange, eventSink: EventSink) { + override fun apply(change: PropertyChange, eventSink: UiEventSink) { throw UnsupportedOperationException() } diff --git a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ProtocolFactoryTest.kt b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ProtocolFactoryTest.kt index 275c9b63a1..f49c965f18 100644 --- a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ProtocolFactoryTest.kt +++ b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ProtocolFactoryTest.kt @@ -21,7 +21,6 @@ import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory import app.cash.redwood.protocol.ChildrenTag 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.ModifierElement @@ -258,7 +257,7 @@ class ProtocolFactoryTest { ) val textInput = factory.createNode(Id(1), WidgetTag(5))!! - val throwingEventSink = EventSink { error(it) } + val throwingEventSink = UiEventSink { error(it) } textInput.apply(PropertyChange(Id(1), PropertyTag(2), JsonPrimitive("PT10S")), throwingEventSink) assertThat((textInput.widget.value as TextInputValue).customType).isEqualTo(10.seconds) @@ -275,7 +274,7 @@ class ProtocolFactoryTest { val button = factory.createNode(Id(1), WidgetTag(4))!! val change = PropertyChange(Id(1), PropertyTag(345432)) - val eventSink = EventSink { throw UnsupportedOperationException() } + val eventSink = UiEventSink { throw UnsupportedOperationException() } val t = assertFailsWith { button.apply(change, eventSink) } @@ -315,12 +314,12 @@ class ProtocolFactoryTest { ) val textInput = factory.createNode(Id(1), WidgetTag(5))!! - val eventSink = RecordingEventSink() + val eventSink = RecordingUiEventSink() textInput.apply(PropertyChange(Id(1), PropertyTag(4), JsonPrimitive(true)), eventSink) (textInput.widget.value as TextInputValue).onChangeCustomType!!.invoke(10.seconds) - assertThat(eventSink.events.single()) + assertThat(eventSink.events.single().toProtocol(json)) .isEqualTo(Event(Id(1), EventTag(4), listOf(JsonPrimitive("PT10S")))) } } diff --git a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingEventSink.kt b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingUiEventSink.kt similarity index 74% rename from redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingEventSink.kt rename to redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingUiEventSink.kt index b292591154..919fed42db 100644 --- a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingEventSink.kt +++ b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/RecordingUiEventSink.kt @@ -15,13 +15,10 @@ */ package app.cash.redwood.protocol.host -import app.cash.redwood.protocol.Event -import app.cash.redwood.protocol.EventSink +class RecordingUiEventSink : UiEventSink { + val events = mutableListOf() -class RecordingEventSink : EventSink { - val events = mutableListOf() - - override fun sendEvent(event: Event) { - events += event + override fun sendEvent(uiEvent: UiEvent) { + events += uiEvent } } diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt index 68885f0f64..3ce8825b1c 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt @@ -26,6 +26,7 @@ import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolEvent import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolProperty import app.cash.redwood.tooling.schema.Schema import app.cash.redwood.tooling.schema.Widget +import com.squareup.kotlinpoet.ANY import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -99,7 +100,7 @@ internal fun generateProtocolFactory( addType( TypeSpec.classBuilder(type) .addTypeVariable(typeVariableW) - .addSuperinterface(WidgetProtocol.GeneratedProtocolFactory.parameterizedBy(typeVariableW)) + .addSuperinterface(ProtocolHost.GeneratedProtocolFactory.parameterizedBy(typeVariableW)) .optIn(Stdlib.ExperimentalObjCName, Redwood.RedwoodCodegenApi) .addAnnotation( AnnotationSpec.builder(Stdlib.ObjCName) @@ -116,8 +117,8 @@ internal fun generateProtocolFactory( .build(), ) .addParameter( - ParameterSpec.builder("mismatchHandler", WidgetProtocol.ProtocolMismatchHandler) - .defaultValue("%T.Throwing", WidgetProtocol.ProtocolMismatchHandler) + ParameterSpec.builder("mismatchHandler", ProtocolHost.ProtocolMismatchHandler) + .defaultValue("%T.Throwing", ProtocolHost.ProtocolMismatchHandler) .build(), ) .build(), @@ -133,7 +134,7 @@ internal fun generateProtocolFactory( .build(), ) .addProperty( - PropertySpec.builder("mismatchHandler", WidgetProtocol.ProtocolMismatchHandler, PRIVATE) + PropertySpec.builder("mismatchHandler", ProtocolHost.ProtocolMismatchHandler, PRIVATE) .initializer("mismatchHandler") .build(), ) @@ -183,7 +184,7 @@ internal fun generateProtocolFactory( .addParameter("tag", WidgetTag) .addAnnotation(Redwood.RedwoodCodegenApi) .returns( - WidgetProtocol.ProtocolNode.parameterizedBy(typeVariableW) + ProtocolHost.ProtocolNode.parameterizedBy(typeVariableW) .copy(nullable = true), ) .beginControlFlow("return when (tag.value)") @@ -257,14 +258,14 @@ internal class ProtocolButton( private val serializer_0: KSerializer = json.serializersModule.serializer() private val serializer_1: KSerializer = json.serializersModule.serializer() - public override fun apply(change: PropertyChange, eventSink: EventSink): Unit { + public override fun apply(change: PropertyChange, eventSink: UiEventSink): Unit { val widget = _widget ?: error("detached") when (change.tag.value) { 1 -> widget.text(json.decodeFromJsonElement(serializer_0, change.value)) 2 -> widget.enabled(json.decodeFromJsonElement(serializer_1, change.value)) 3 -> { val onClick: (() -> Unit)? = if (change.value.jsonPrimitive.boolean) { - OnClick(json, change.id, eventSink) + OnClick(json, id, eventSink) } else { null } @@ -295,7 +296,7 @@ internal fun generateProtocolNode( ): FileSpec { val type = schema.protocolNodeType(widget, host) val widgetType = schema.widgetType(widget).parameterizedBy(typeVariableW) - val protocolType = WidgetProtocol.ProtocolNode.parameterizedBy(typeVariableW) + val protocolType = ProtocolHost.ProtocolNode.parameterizedBy(typeVariableW) val (childrens, properties) = widget.traits.partition { it is ProtocolChildren } return buildFileSpec(type) { addAnnotation(suppressDeprecations) @@ -310,7 +311,7 @@ internal fun generateProtocolNode( .addParameter("id", Id) .addParameter("widget", widgetType) .addParameter("json", KotlinxSerialization.Json) - .addParameter("mismatchHandler", WidgetProtocol.ProtocolMismatchHandler) + .addParameter("mismatchHandler", ProtocolHost.ProtocolMismatchHandler) .build(), ) .addSuperclassConstructorParameter("id") @@ -336,7 +337,7 @@ internal fun generateProtocolNode( .build(), ) .addProperty( - PropertySpec.builder("mismatchHandler", WidgetProtocol.ProtocolMismatchHandler, PRIVATE) + PropertySpec.builder("mismatchHandler", ProtocolHost.ProtocolMismatchHandler, PRIVATE) .initializer("mismatchHandler") .build(), ) @@ -354,7 +355,7 @@ internal fun generateProtocolNode( FunSpec.builder("apply") .addModifiers(OVERRIDE) .addParameter("change", Protocol.PropertyChange) - .addParameter("eventSink", Protocol.EventSink) + .addParameter("eventSink", ProtocolHost.UiEventSink) .apply { if (properties.isNotEmpty()) { addStatement("val widget = _widget ?: error(%S)", "detached") @@ -385,22 +386,22 @@ internal fun generateProtocolNode( KotlinxSerialization.jsonPrimitive, KotlinxSerialization.jsonBoolean, ) - val arguments = mutableListOf() - for (parameterFqType in trait.parameterTypes) { - val parameterType = parameterFqType.asTypeName() - val serializerId = serializerIds.computeIfAbsent(parameterType) { - nextSerializerId++ - } - arguments += CodeBlock.of("serializer_%L", serializerId) - } if (trait.parameterTypes.isEmpty()) { addStatement( - "%L(json, change.id, eventSink)::invoke", + "%L(id, eventSink)::invoke", trait.eventHandlerName, ) } else { + val arguments = mutableListOf() + for (parameterFqType in trait.parameterTypes) { + val parameterType = parameterFqType.asTypeName() + val serializerId = serializerIds.computeIfAbsent(parameterType) { + nextSerializerId++ + } + arguments += CodeBlock.of("serializer_%L", serializerId) + } addStatement( - "%L(json, change.id, eventSink, %L)::invoke", + "%L(id, eventSink, %L)::invoke", trait.eventHandlerName, arguments.joinToCode(), ) @@ -444,9 +445,9 @@ internal fun generateProtocolNode( for (children in childrens) { addProperty( - PropertySpec.builder(children.name, WidgetProtocol.ProtocolChildren.parameterizedBy(typeVariableW)) + PropertySpec.builder(children.name, ProtocolHost.ProtocolChildren.parameterizedBy(typeVariableW)) .addModifiers(PRIVATE) - .initializer("%T(widget.%N)", WidgetProtocol.ProtocolChildren, children.name) + .initializer("%T(widget.%N)", ProtocolHost.ProtocolChildren, children.name) .build(), ) } @@ -455,7 +456,7 @@ internal fun generateProtocolNode( FunSpec.builder("children") .addModifiers(OVERRIDE) .addParameter("tag", Protocol.ChildrenTag) - .returns(WidgetProtocol.ProtocolChildren.parameterizedBy(typeVariableW).copy(nullable = true)) + .returns(ProtocolHost.ProtocolChildren.parameterizedBy(typeVariableW).copy(nullable = true)) .apply { if (childrens.isNotEmpty()) { beginControlFlow("return when (tag.value)") @@ -525,21 +526,24 @@ private val ProtocolEvent.eventHandlerName: String */ /* private class OnClick( - private val json: Json, private val id: Id, - private val eventSink: EventSink, + private val eventSink: UiEventSink, private val serializer_0: KSerializer, private val serializer_1: KSerializer, ) : (Int, String) -> Unit { override fun invoke(arg0: Int, arg1: String) { eventSink.sendEvent( - Event( + UiEvent( id, EventTag(3), listOf( - json.encodeToJsonElement(serializer_0, arg0), - json.encodeToJsonElement(serializer_1, arg1), - ) + arg0, + arg1, + ), + listOf( + serializer_0, + serializer_1, + ), ) ) } @@ -550,15 +554,20 @@ private fun generateEventHandler( ): TypeSpec { val constructor = FunSpec.constructorBuilder() val invoke = FunSpec.builder("invoke") + .addAnnotation( + AnnotationSpec.builder(Suppress::class) + .addMember("%S", "UNCHECKED_CAST") + .build(), + ) val classBuilder = TypeSpec.classBuilder(trait.eventHandlerName) .addModifiers(PRIVATE) - addConstructorParameterAndProperty(classBuilder, constructor, "json", KotlinxSerialization.Json) addConstructorParameterAndProperty(classBuilder, constructor, "id", Protocol.Id) - addConstructorParameterAndProperty(classBuilder, constructor, "eventSink", Protocol.EventSink) + addConstructorParameterAndProperty(classBuilder, constructor, "eventSink", ProtocolHost.UiEventSink) val arguments = mutableListOf() + val serializers = mutableListOf() for ((index, parameterFqType) in trait.parameterTypes.withIndex()) { val parameterType = parameterFqType.asTypeName() val serializerType = KotlinxSerialization.KSerializer.parameterizedBy(parameterType) @@ -568,27 +577,28 @@ private fun generateEventHandler( addConstructorParameterAndProperty(classBuilder, constructor, serializerId, serializerType) invoke.addParameter(ParameterSpec(parameterName, parameterType)) - arguments += CodeBlock.of( - "json.encodeToJsonElement(%L, %L)", - serializerId, - parameterName, + arguments += CodeBlock.of("%L", parameterName) + serializers += CodeBlock.of( + "%L as %T", serializerId, + KotlinxSerialization.KSerializer.parameterizedBy(ANY.copy(nullable = true)), ) } - if (arguments.isEmpty()) { + if (serializers.isEmpty()) { invoke.addCode( "eventSink.sendEvent(%T(id, %T(%L)))", - Protocol.Event, + ProtocolHost.UiEvent, Protocol.EventTag, trait.tag, ) } else { invoke.addCode( - "eventSink.sendEvent(⇥\n%T(⇥\nid,\n%T(%L),\nlistOf(⇥\n%L,\n⇤),\n⇤),\n⇤)", - Protocol.Event, + "eventSink.sendEvent(⇥\n%T(⇥\nid,\n%T(%L),\nlistOf(⇥\n%L,\n⇤),\nlistOf(⇥\n%L,\n⇤),\n⇤),\n⇤)", + ProtocolHost.UiEvent, Protocol.EventTag, trait.tag, arguments.joinToCode(separator = ",\n"), + serializers.joinToCode(separator = ",\n"), ) } 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 826b7e0c38..56305e7104 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 @@ -47,12 +47,14 @@ internal object ProtocolGuest { val ProtocolWidgetSystemFactory = ClassName("app.cash.redwood.protocol.guest", "ProtocolWidgetSystemFactory") } -internal object WidgetProtocol { +internal object ProtocolHost { val ProtocolMismatchHandler = ClassName("app.cash.redwood.protocol.host", "ProtocolMismatchHandler") val ProtocolNode = ClassName("app.cash.redwood.protocol.host", "ProtocolNode") val ProtocolChildren = ClassName("app.cash.redwood.protocol.host", "ProtocolChildren") val GeneratedProtocolFactory = ClassName("app.cash.redwood.protocol.host", "GeneratedProtocolFactory") + val UiEvent = ClassName("app.cash.redwood.protocol.host", "UiEvent") + val UiEventSink = ClassName("app.cash.redwood.protocol.host", "UiEventSink") } internal object Redwood { diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt index c267c2d2e1..0cc57b0ddf 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt @@ -15,8 +15,8 @@ */ package app.cash.redwood.treehouse -import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.SnapshotChangeList +import app.cash.redwood.protocol.host.UiEventSink import app.cash.redwood.protocol.host.HostProtocolAdapter import app.cash.redwood.protocol.host.ProtocolMismatchHandler import app.cash.redwood.protocol.host.hostRedwoodVersion @@ -31,7 +31,7 @@ import kotlinx.serialization.json.Json public class ChangeListRenderer( private val json: Json, ) { - private val refuseAllEvents = EventSink { event -> + private val refuseAllEvents = UiEventSink { event -> throw IllegalStateException("unexpected event: $event") } 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 7907437c00..82f642c467 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 @@ -16,10 +16,11 @@ package app.cash.redwood.treehouse import app.cash.redwood.protocol.Change -import app.cash.redwood.protocol.Event import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.host.HostProtocolAdapter import app.cash.redwood.protocol.host.ProtocolFactory +import app.cash.redwood.protocol.host.UiEvent +import app.cash.redwood.protocol.host.UiEventSink import app.cash.redwood.ui.OnBackPressedCallback import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.Json private class State( val viewState: ViewState, @@ -333,7 +335,7 @@ private class ViewContentCodeBinding( private var treehouseUiOrNull: ZiplineTreehouseUi? = null /** Note that this is necessary to break the retain cycle between host and guest. */ - private val eventBridge = EventBridge(dispatchers.zipline, bindingScope) + private val eventBridge = EventBridge(codeSession.json, dispatchers.zipline, bindingScope) /** Only accessed on [TreehouseDispatchers.ui]. Empty after [initView]. */ private val changesAwaitingInitView = ArrayDeque>() @@ -531,19 +533,23 @@ private class ViewContentCodeBinding( * problems when mixing garbage-collected Kotlin objects with reference-counted Swift objects. */ private class EventBridge( + private val json: Json, // Both properties are only accessed on the UI dispatcher and null after cancel(). var ziplineDispatcher: CoroutineDispatcher?, var bindingScope: CoroutineScope?, -) : EventSink { +) : UiEventSink { // Only accessed on the Zipline dispatcher and null after cancel(). var delegate: EventSink? = null /** Send an event from the UI to Zipline. */ - override fun sendEvent(event: Event) { + override fun sendEvent(uiEvent: UiEvent) { // Send UI events on the zipline dispatcher. val dispatcher = this.ziplineDispatcher ?: return val bindingScope = this.bindingScope ?: return bindingScope.launch(dispatcher) { + // Perform initial serialization of event arguments into JSON model after the thread hop. + val event = uiEvent.toProtocol(json) + delegate?.sendEvent(event) } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt index ae82646700..dcd083aca0 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt @@ -17,12 +17,12 @@ package app.cash.redwood.treehouse import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.protocol.ChildrenTag -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.PropertyChange import app.cash.redwood.protocol.WidgetTag +import app.cash.redwood.protocol.host.UiEvent +import app.cash.redwood.protocol.host.UiEventSink import app.cash.redwood.protocol.host.ProtocolChildren import app.cash.redwood.protocol.host.ProtocolNode import kotlinx.serialization.json.JsonPrimitive @@ -37,10 +37,10 @@ internal class FakeProtocolNode( ) : ProtocolNode(id, tag) { override val widget = FakeWidget() - override fun apply(change: PropertyChange, eventSink: EventSink) { + override fun apply(change: PropertyChange, eventSink: UiEventSink) { widget.label = (change.value as JsonPrimitive).content widget.onClick = { - eventSink.sendEvent(Event(Id(1), EventTag(1))) + eventSink.sendEvent(UiEvent(Id(1), EventTag(1))) } }