diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt index 89f0855f6f..9d188d745d 100644 --- a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt @@ -58,12 +58,15 @@ public interface RedwoodComposition { /** * @param scope A [CoroutineScope] whose [coroutineContext][kotlin.coroutines.CoroutineContext] * must have a [MonotonicFrameClock] key which is being ticked. + * @param onChanges Invoked when changes are being applied to the widget tree. Multiple rounds of + * changes can be applied in a single frame or setContent call. This should be relied on only for + * a signal that changes occurred, not for when they are complete. */ public fun RedwoodComposition( scope: CoroutineScope, view: RedwoodView, widgetSystem: WidgetSystem, - onEndChanges: () -> Unit = {}, + onChanges: () -> Unit = {}, ): RedwoodComposition { view.children.remove(0, view.children.widgets.size) @@ -100,13 +103,16 @@ public fun RedwoodComposition( saveableStateRegistry, view.uiConfiguration, widgetSystem, - onEndChanges, + onChanges, ) } /** * @param scope A [CoroutineScope] whose [coroutineContext][kotlin.coroutines.CoroutineContext] * must have a [MonotonicFrameClock] key which is being ticked. + * @param onChanges Invoked when changes are being applied to the widget tree. Multiple rounds of + * changes can be applied in a single frame or setContent call. This should be relied on only for + * a signal that changes occurred, not for when they are complete. */ public fun RedwoodComposition( scope: CoroutineScope, @@ -115,14 +121,14 @@ public fun RedwoodComposition( saveableStateRegistry: SaveableStateRegistry?, uiConfigurations: StateFlow, widgetSystem: WidgetSystem, - onEndChanges: () -> Unit = {}, + onChanges: () -> Unit = {}, ): RedwoodComposition { return WidgetRedwoodComposition( scope, onBackPressedDispatcher, saveableStateRegistry, uiConfigurations, - NodeApplier(widgetSystem, container, onEndChanges), + NodeApplier(widgetSystem, container, onChanges), ) } diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/WidgetApplier.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/WidgetApplier.kt index d8c4685009..20d2ba93c8 100644 --- a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/WidgetApplier.kt +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/WidgetApplier.kt @@ -54,7 +54,7 @@ import kotlin.math.min internal class NodeApplier( override val widgetSystem: WidgetSystem, root: Widget.Children, - private val onEndChanges: () -> Unit, + private val onChanges: () -> Unit, ) : AbstractApplier>(ChildrenNode(root)), RedwoodApplier { private var closed = false @@ -66,6 +66,13 @@ internal class NodeApplier( } } + override fun onBeginChanges() { + super.onBeginChanges() + // We invoke this here rather than in the end change callback to try and ensure + // no one relies on it to signal the end of changes. + onChanges.invoke() + } + override fun onEndChanges() { check(!closed) @@ -75,8 +82,6 @@ internal class NodeApplier( } changedWidgets.clear() } - - onEndChanges.invoke() } override fun insertTopDown(index: Int, instance: Node) { diff --git a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/DefaultGuestProtocolAdapter.kt b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/DefaultGuestProtocolAdapter.kt index 1c1f9b6e24..8a38ef7614 100644 --- a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/DefaultGuestProtocolAdapter.kt +++ b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/DefaultGuestProtocolAdapter.kt @@ -170,7 +170,9 @@ public class DefaultGuestProtocolAdapter( } override fun emitChanges() { - changesSink.sendChanges(takeChanges()) + if (changes.isNotEmpty()) { + changesSink.sendChanges(takeChanges()) + } } public override fun removeWidget(id: Id) { diff --git a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/GuestProtocolAdapter.kt b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/GuestProtocolAdapter.kt index 45afe6e27f..45cccc98dd 100644 --- a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/GuestProtocolAdapter.kt +++ b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/GuestProtocolAdapter.kt @@ -66,6 +66,10 @@ public abstract class GuestProtocolAdapter( public abstract fun initChangesSink(changesSink: ChangesSink) + /** + * Write changes to the underlying [ChangesSink]. + * This function may no-op if there are no changes to send. + */ public abstract fun emitChanges() @RedwoodCodegenApi diff --git a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/ProtocolRedwoodComposition.kt b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/ProtocolRedwoodComposition.kt index 2d85016251..f1ed6e6c9c 100644 --- a/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/ProtocolRedwoodComposition.kt +++ b/redwood-protocol-guest/src/commonMain/kotlin/app/cash/redwood/protocol/guest/ProtocolRedwoodComposition.kt @@ -38,7 +38,6 @@ public fun ProtocolRedwoodComposition( onBackPressedDispatcher: OnBackPressedDispatcher, saveableStateRegistry: SaveableStateRegistry?, uiConfigurations: StateFlow, - onEndChanges: () -> Unit = {}, ): RedwoodComposition { val composition = RedwoodComposition( scope = scope, @@ -47,7 +46,6 @@ public fun ProtocolRedwoodComposition( saveableStateRegistry = saveableStateRegistry, uiConfigurations = uiConfigurations, widgetSystem = guestAdapter.widgetSystem, - onEndChanges = onEndChanges, ) return ProtocolRedwoodComposition(composition, widgetVersion) } diff --git a/redwood-protocol-guest/src/commonTest/kotlin/app/cash/redwood/protocol/guest/ProtocolTest.kt b/redwood-protocol-guest/src/commonTest/kotlin/app/cash/redwood/protocol/guest/ProtocolTest.kt index 48e517da70..a1e034a7ff 100644 --- a/redwood-protocol-guest/src/commonTest/kotlin/app/cash/redwood/protocol/guest/ProtocolTest.kt +++ b/redwood-protocol-guest/src/commonTest/kotlin/app/cash/redwood/protocol/guest/ProtocolTest.kt @@ -46,7 +46,6 @@ import app.cash.redwood.ui.UiConfiguration import assertk.assertFailure import assertk.assertThat import assertk.assertions.containsExactly -import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.message @@ -86,9 +85,6 @@ class ProtocolTest { }, saveableStateRegistry = null, uiConfigurations = MutableStateFlow(UiConfiguration()), - onEndChanges = { - assertThat(guestAdapter.takeChanges()).isEmpty() - }, ) var actualDisplayVersion = 0U diff --git a/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt b/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt index 892825b680..02de47f4d4 100644 --- a/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt +++ b/redwood-testing/src/commonMain/kotlin/app/cash/redwood/testing/TestRedwoodComposition.kt @@ -120,7 +120,7 @@ private class RealTestRedwoodComposition( saveableStateRegistry = savedStateRegistry, uiConfigurations = uiConfigurations, widgetSystem = widgetSystem, - onEndChanges = { hasChanges = true }, + onChanges = { hasChanges = true }, ) override fun setContent(content: @Composable () -> Unit) { diff --git a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt index a0f4b6e76d..950539f00f 100644 --- a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt +++ b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt @@ -22,7 +22,6 @@ import app.cash.redwood.compose.current import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory import app.cash.redwood.leaks.LeakDetector -import app.cash.redwood.protocol.Change import app.cash.redwood.protocol.ChildrenChange.Add import app.cash.redwood.protocol.ChildrenTag import app.cash.redwood.protocol.Create @@ -115,7 +114,6 @@ class ViewTreesTest { .isEqualTo(expected) // Validate that the normal Compose protocol backend produces the same list of changes. - val protocolChanges = mutableListOf() val guestAdapter = DefaultGuestProtocolAdapter( hostVersion = hostRedwoodVersion, widgetSystemFactory = TestSchemaProtocolWidgetSystemFactory, @@ -123,7 +121,6 @@ class ViewTreesTest { val composition = ProtocolRedwoodComposition( scope = this + BroadcastFrameClock(), guestAdapter = guestAdapter, - onEndChanges = { protocolChanges += guestAdapter.takeChanges() }, widgetVersion = UInt.MAX_VALUE, onBackPressedDispatcher = object : OnBackPressedDispatcher { override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { @@ -138,7 +135,7 @@ class ViewTreesTest { composition.setContent(content) composition.cancel() - assertThat(protocolChanges).isEqualTo(expected) + assertThat(guestAdapter.takeChanges()).isEqualTo(expected) // Ensure when the changes are applied with the widget protocol we get equivalent values. val widgetSystem = TestSchemaWidgetSystem( diff --git a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt index c03315526d..0725962da3 100644 --- a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt +++ b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt @@ -15,8 +15,6 @@ */ package app.cash.redwood.treehouse -import androidx.compose.runtime.BroadcastFrameClock -import androidx.compose.runtime.MonotonicFrameClock import app.cash.redwood.protocol.EventTag import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.RedwoodVersion @@ -38,6 +36,7 @@ public class StandardAppLifecycle( ) : AppLifecycle { private var started = false private lateinit var host: Host + private val frameListeners = mutableListOf() override val guestProtocolVersion: RedwoodVersion get() = guestRedwoodVersion @@ -50,12 +49,11 @@ public class StandardAppLifecycle( } } - private val broadcastFrameClock: BroadcastFrameClock = BroadcastFrameClock { + internal fun requestHostFrame() { if (started) { host.requestFrame() } } - public val frameClock: MonotonicFrameClock get() = broadcastFrameClock internal val mismatchHandler: ProtocolMismatchHandler = object : ProtocolMismatchHandler { override fun onUnknownEvent(widgetTag: WidgetTag, tag: EventTag) { @@ -87,6 +85,20 @@ public class StandardAppLifecycle( } override fun sendFrame(timeNanos: Long) { - broadcastFrameClock.sendFrame(timeNanos) + for (frameListener in frameListeners) { + frameListener.onFrame(timeNanos) + } + } + + internal fun addFrameListener(listener: FrameListener) { + frameListeners += listener + } + + internal fun removeFrameListener(listener: FrameListener) { + frameListeners.remove(listener) + } + + internal fun interface FrameListener { + fun onFrame(timeNanos: Long) } } diff --git a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt index 15956f2714..58f2a75684 100644 --- a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt +++ b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/treehouseCompose.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.saveable.SaveableStateRegistry import app.cash.redwood.compose.RedwoodComposition import app.cash.redwood.protocol.Change @@ -56,7 +57,18 @@ private class RedwoodZiplineTreehouseUi( private val guestAdapter: GuestProtocolAdapter, ) : ZiplineTreehouseUi, ZiplineScoped, - EventSink by guestAdapter { + EventSink by guestAdapter, + StandardAppLifecycle.FrameListener { + + private val clock = BroadcastFrameClock { + appLifecycle.requestHostFrame() + } + private var hasChanges = false + + override fun onFrame(timeNanos: Long) { + clock.sendFrame(timeNanos) + guestAdapter.emitChanges() + } /** * By overriding [ZiplineScoped.scope], all services passed into [start] are added to this scope, @@ -125,11 +137,12 @@ private class RedwoodZiplineTreehouseUi( guestAdapter.initChangesSink(changesSink) + appLifecycle.addFrameListener(this) + val composition = ProtocolRedwoodComposition( - scope = coroutineScope + appLifecycle.frameClock, + scope = coroutineScope + clock, guestAdapter = guestAdapter, widgetVersion = appLifecycle.widgetVersion, - onEndChanges = { guestAdapter.emitChanges() }, onBackPressedDispatcher = host.asOnBackPressedDispatcher(), saveableStateRegistry = saveableStateRegistry, uiConfigurations = host.uiConfigurations, @@ -137,6 +150,11 @@ private class RedwoodZiplineTreehouseUi( this.composition = composition composition.bind(treehouseUi) + + // Explicitly emit the initial changes produced by calling 'setContent' (within 'bind'). + // All other changes are initiated and emitted by the [onFrame] callback. + hasChanges = false + guestAdapter.emitChanges() } override fun snapshotState(): StateSnapshot { @@ -145,6 +163,7 @@ private class RedwoodZiplineTreehouseUi( } override fun close() { + appLifecycle.removeFrameListener(this) coroutineScope.coroutineContext.job.invokeOnCompletion { scope.close() } diff --git a/redwood-treehouse-guest/src/jsMain/kotlin/app/cash/redwood/treehouse/ProtocolBridgeJs.kt b/redwood-treehouse-guest/src/jsMain/kotlin/app/cash/redwood/treehouse/ProtocolBridgeJs.kt index f329d3ff81..534ef21797 100644 --- a/redwood-treehouse-guest/src/jsMain/kotlin/app/cash/redwood/treehouse/ProtocolBridgeJs.kt +++ b/redwood-treehouse-guest/src/jsMain/kotlin/app/cash/redwood/treehouse/ProtocolBridgeJs.kt @@ -220,8 +220,10 @@ internal class FastGuestProtocolAdapter( } override fun emitChanges() { - sendChanges(changesSinkService, arrayOf(changes)) - changes.clear() + if (changes.length > 0) { + sendChanges(changesSinkService, arrayOf(changes)) + changes.clear() + } } override fun removeWidget(id: Id) {