From 0779b75b2d7c87fb266824ce5c97478f8219bb4e 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 +- .../api/redwood-protocol-host.api | 21 ++++- .../api/redwood-protocol-host.klib.api | 26 +++++- 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 | 89 +++++++++++-------- .../app/cash/redwood/tooling/codegen/types.kt | 4 +- .../redwood/treehouse/ChangeListRenderer.kt | 4 +- .../redwood/treehouse/TreehouseAppContent.kt | 14 ++- .../redwood/treehouse/FakeProtocolNode.kt | 8 +- 15 files changed, 185 insertions(+), 74 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/api/redwood-protocol-host.api b/redwood-protocol-host/api/redwood-protocol-host.api index dd61a2e8f5..8c7493bd35 100644 --- a/redwood-protocol-host/api/redwood-protocol-host.api +++ b/redwood-protocol-host/api/redwood-protocol-host.api @@ -5,7 +5,7 @@ public abstract interface class app/cash/redwood/protocol/host/GeneratedProtocol } public final class app/cash/redwood/protocol/host/HostProtocolAdapter : app/cash/redwood/protocol/ChangesSink { - public synthetic fun (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/EventSink;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/host/UiEventSink;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun close ()V public fun sendChanges (Ljava/util/List;)V } @@ -35,7 +35,7 @@ public final class app/cash/redwood/protocol/host/ProtocolMismatchHandler$Compan public abstract class app/cash/redwood/protocol/host/ProtocolNode { public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V - public abstract fun apply (Lapp/cash/redwood/protocol/PropertyChange;Lapp/cash/redwood/protocol/EventSink;)V + public abstract fun apply (Lapp/cash/redwood/protocol/PropertyChange;Lapp/cash/redwood/protocol/host/UiEventSink;)V public abstract fun children-dBpC-2Y (I)Lapp/cash/redwood/protocol/host/ProtocolChildren; public abstract fun detach ()V public final fun getId-0HhLjSo ()I @@ -45,6 +45,23 @@ public abstract class app/cash/redwood/protocol/host/ProtocolNode { public abstract fun visitIds (Lkotlin/jvm/functions/Function1;)V } +public final class app/cash/redwood/protocol/host/UiEvent { + public synthetic fun (IILjava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IILjava/util/List;Ljava/util/List;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getArgs ()Ljava/util/List; + public final fun getId-0HhLjSo ()I + public final fun getSerializationStrategies ()Ljava/util/List; + public final fun getTag-RNF89mI ()I + public fun hashCode ()I + public final fun toProtocol (Lkotlinx/serialization/json/Json;)Lapp/cash/redwood/protocol/Event; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class app/cash/redwood/protocol/host/UiEventSink { + public abstract fun sendEvent (Lapp/cash/redwood/protocol/host/UiEvent;)V +} + public final class app/cash/redwood/protocol/host/VersionKt { public static final fun getHostRedwoodVersion ()Ljava/lang/String; } diff --git a/redwood-protocol-host/api/redwood-protocol-host.klib.api b/redwood-protocol-host/api/redwood-protocol-host.klib.api index 327e90bef7..656e626051 100644 --- a/redwood-protocol-host/api/redwood-protocol-host.klib.api +++ b/redwood-protocol-host/api/redwood-protocol-host.klib.api @@ -6,6 +6,10 @@ // - Show declarations: true // Library unique name: +abstract fun interface app.cash.redwood.protocol.host/UiEventSink { // app.cash.redwood.protocol.host/UiEventSink|null[0] + abstract fun sendEvent(app.cash.redwood.protocol.host/UiEvent) // app.cash.redwood.protocol.host/UiEventSink.sendEvent|sendEvent(app.cash.redwood.protocol.host.UiEvent){}[0] +} + abstract interface <#A: kotlin/Any> app.cash.redwood.protocol.host/GeneratedProtocolFactory : app.cash.redwood.protocol.host/ProtocolFactory<#A> { // app.cash.redwood.protocol.host/GeneratedProtocolFactory|null[0] abstract fun createModifier(app.cash.redwood.protocol/ModifierElement): app.cash.redwood/Modifier // app.cash.redwood.protocol.host/GeneratedProtocolFactory.createModifier|createModifier(app.cash.redwood.protocol.ModifierElement){}[0] abstract fun createNode(app.cash.redwood.protocol/Id, app.cash.redwood.protocol/WidgetTag): app.cash.redwood.protocol.host/ProtocolNode<#A>? // app.cash.redwood.protocol.host/GeneratedProtocolFactory.createNode|createNode(app.cash.redwood.protocol.Id;app.cash.redwood.protocol.WidgetTag){}[0] @@ -39,7 +43,7 @@ abstract class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolNode { // final val widgetTag // app.cash.redwood.protocol.host/ProtocolNode.widgetTag|{}widgetTag[0] final fun (): app.cash.redwood.protocol/WidgetTag // app.cash.redwood.protocol.host/ProtocolNode.widgetTag.|(){}[0] - abstract fun apply(app.cash.redwood.protocol/PropertyChange, app.cash.redwood.protocol/EventSink) // app.cash.redwood.protocol.host/ProtocolNode.apply|apply(app.cash.redwood.protocol.PropertyChange;app.cash.redwood.protocol.EventSink){}[0] + abstract fun apply(app.cash.redwood.protocol/PropertyChange, app.cash.redwood.protocol.host/UiEventSink) // app.cash.redwood.protocol.host/ProtocolNode.apply|apply(app.cash.redwood.protocol.PropertyChange;app.cash.redwood.protocol.host.UiEventSink){}[0] abstract fun children(app.cash.redwood.protocol/ChildrenTag): app.cash.redwood.protocol.host/ProtocolChildren<#A>? // app.cash.redwood.protocol.host/ProtocolNode.children|children(app.cash.redwood.protocol.ChildrenTag){}[0] abstract fun detach() // app.cash.redwood.protocol.host/ProtocolNode.detach|detach(){}[0] abstract fun visitIds(kotlin/Function1) // app.cash.redwood.protocol.host/ProtocolNode.visitIds|visitIds(kotlin.Function1){}[0] @@ -47,7 +51,7 @@ abstract class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolNode { // } final class <#A: kotlin/Any> app.cash.redwood.protocol.host/HostProtocolAdapter : app.cash.redwood.protocol/ChangesSink { // app.cash.redwood.protocol.host/HostProtocolAdapter|null[0] - constructor (app.cash.redwood.protocol/RedwoodVersion, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.protocol.host/ProtocolFactory<#A>, app.cash.redwood.protocol/EventSink) // app.cash.redwood.protocol.host/HostProtocolAdapter.|(app.cash.redwood.protocol.RedwoodVersion;app.cash.redwood.widget.Widget.Children<1:0>;app.cash.redwood.protocol.host.ProtocolFactory<1:0>;app.cash.redwood.protocol.EventSink){}[0] + constructor (app.cash.redwood.protocol/RedwoodVersion, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.protocol.host/ProtocolFactory<#A>, app.cash.redwood.protocol.host/UiEventSink) // app.cash.redwood.protocol.host/HostProtocolAdapter.|(app.cash.redwood.protocol.RedwoodVersion;app.cash.redwood.widget.Widget.Children<1:0>;app.cash.redwood.protocol.host.ProtocolFactory<1:0>;app.cash.redwood.protocol.host.UiEventSink){}[0] final fun close() // app.cash.redwood.protocol.host/HostProtocolAdapter.close|close(){}[0] final fun sendChanges(kotlin.collections/List) // app.cash.redwood.protocol.host/HostProtocolAdapter.sendChanges|sendChanges(kotlin.collections.List){}[0] @@ -63,5 +67,23 @@ final class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolChildren { / final fun visitIds(kotlin/Function1) // app.cash.redwood.protocol.host/ProtocolChildren.visitIds|visitIds(kotlin.Function1){}[0] } +final class app.cash.redwood.protocol.host/UiEvent { // app.cash.redwood.protocol.host/UiEvent|null[0] + constructor (app.cash.redwood.protocol/Id, app.cash.redwood.protocol/EventTag, kotlin.collections/List = ..., kotlin.collections/List> = ...) // app.cash.redwood.protocol.host/UiEvent.|(app.cash.redwood.protocol.Id;app.cash.redwood.protocol.EventTag;kotlin.collections.List;kotlin.collections.List>){}[0] + + final val args // app.cash.redwood.protocol.host/UiEvent.args|{}args[0] + final fun (): kotlin.collections/List // app.cash.redwood.protocol.host/UiEvent.args.|(){}[0] + final val id // app.cash.redwood.protocol.host/UiEvent.id|{}id[0] + final fun (): app.cash.redwood.protocol/Id // app.cash.redwood.protocol.host/UiEvent.id.|(){}[0] + final val serializationStrategies // app.cash.redwood.protocol.host/UiEvent.serializationStrategies|{}serializationStrategies[0] + final fun (): kotlin.collections/List> // app.cash.redwood.protocol.host/UiEvent.serializationStrategies.|(){}[0] + final val tag // app.cash.redwood.protocol.host/UiEvent.tag|{}tag[0] + final fun (): app.cash.redwood.protocol/EventTag // app.cash.redwood.protocol.host/UiEvent.tag.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // app.cash.redwood.protocol.host/UiEvent.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.cash.redwood.protocol.host/UiEvent.hashCode|hashCode(){}[0] + final fun toProtocol(kotlinx.serialization.json/Json): app.cash.redwood.protocol/Event // app.cash.redwood.protocol.host/UiEvent.toProtocol|toProtocol(kotlinx.serialization.json.Json){}[0] + final fun toString(): kotlin/String // app.cash.redwood.protocol.host/UiEvent.toString|toString(){}[0] +} + final val app.cash.redwood.protocol.host/hostRedwoodVersion // app.cash.redwood.protocol.host/hostRedwoodVersion|{}hostRedwoodVersion[0] final fun (): app.cash.redwood.protocol/RedwoodVersion // app.cash.redwood.protocol.host/hostRedwoodVersion.|(){}[0] 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..7f78ec6008 --- /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..a7a8f813dc 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,29 @@ private fun generateEventHandler( addConstructorParameterAndProperty(classBuilder, constructor, serializerId, serializerType) invoke.addParameter(ParameterSpec(parameterName, parameterType)) - arguments += CodeBlock.of( - "json.encodeToJsonElement(%L, %L)", + arguments += CodeBlock.of("%L", parameterName) + serializers += CodeBlock.of( + "%L as %T", serializerId, - parameterName, + 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..9dcba60150 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,10 +15,10 @@ */ package app.cash.redwood.treehouse -import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.SnapshotChangeList import app.cash.redwood.protocol.host.HostProtocolAdapter import app.cash.redwood.protocol.host.ProtocolMismatchHandler +import app.cash.redwood.protocol.host.UiEventSink import app.cash.redwood.protocol.host.hostRedwoodVersion import kotlinx.serialization.json.Json @@ -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..98022259c6 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,14 +17,14 @@ 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.ProtocolChildren import app.cash.redwood.protocol.host.ProtocolNode +import app.cash.redwood.protocol.host.UiEvent +import app.cash.redwood.protocol.host.UiEventSink 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))) } }