From ebf4516678b0dffd6ff128b2c2927a3329726cea Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Mon, 26 Aug 2024 15:40:54 -0400 Subject: [PATCH] Reapply "Defer serializing event args to JSON (#2218)" (#2239) (#2262) * Reapply "Defer serializing event args to JSON (#2218)" (#2239) This reverts commit 5f8ca01e586eb5dbecb5e351b9d8a11e7145be5d. * Track API change in test --- CHANGELOG.md | 1 + .../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 +-- .../redwood/testing/ViewRecyclingTester.kt | 3 +- .../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 +- 16 files changed, 187 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 9a65deec34..9a2b2b1023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ New: - Introduce a `LoadingStrategy` interface to manage `LazyList` preloading. Changed: +- 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. - `ProtocolFactory` interface is now sealed as arbitrary subtypes were never supported. Only schema-generated subtypes should be used. - `UIViewLazyList` doesn't crash with a `NullPointerException` if cells are added, removed, and re-added without being reused. - Change `UiConfiguration.viewportSize` to be nullable. A null `viewportSize` indicates the viewport's size has not been resolved yet. diff --git a/redwood-protocol-host/api/redwood-protocol-host.api b/redwood-protocol-host/api/redwood-protocol-host.api index 85f67dc21f..23909902e7 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 @@ -46,6 +46,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 307284bb29..98ca783282 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] @@ -41,7 +45,7 @@ abstract class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolNode { // final fun (): app.cash.redwood.protocol/Id // app.cash.redwood.protocol.host/ProtocolNode.id.|(){}[0] final fun (app.cash.redwood.protocol/Id) // app.cash.redwood.protocol.host/ProtocolNode.id.|(app.cash.redwood.protocol.Id){}[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] @@ -49,7 +53,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] @@ -65,5 +69,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 b8ce628d0d..eaa304b2e6 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 = when (factory) { is GeneratedProtocolFactory -> factory @@ -381,7 +380,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 70124a4bc7..319dc5b23c 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 @@ -49,7 +48,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-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt index d8de07da2f..026120e27f 100644 --- a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt +++ b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt @@ -40,6 +40,7 @@ import com.example.redwood.testapp.testing.TestSchemaTestingWidgetFactory import com.example.redwood.testapp.widget.TestSchemaWidgetSystem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json /** * Like [TestSchemaTester], but this also hooks up Redwood's protocol mechanism. That's necessary @@ -64,7 +65,7 @@ class ViewRecyclingTester( container = widgetContainer, factory = widgetProtocolFactory, eventSink = { event -> - guestAdapter.sendEvent(event) + guestAdapter.sendEvent(event.toProtocol(Json.Default)) }, ) 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))) } }