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..c45824125c 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, resulting in multiple invocations + * of this callback. */ 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, resulting in multiple invocations + * of this callback. */ 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/api/redwood-protocol-guest.api b/redwood-protocol-guest/api/redwood-protocol-guest.api index 444ef3539b..d75fd6c3a6 100644 --- a/redwood-protocol-guest/api/redwood-protocol-guest.api +++ b/redwood-protocol-guest/api/redwood-protocol-guest.api @@ -41,8 +41,7 @@ public final class app/cash/redwood/protocol/guest/ProtocolMismatchHandler$Compa } public final class app/cash/redwood/protocol/guest/ProtocolRedwoodCompositionKt { - public static final fun ProtocolRedwoodComposition-C-DY9sE (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/protocol/guest/GuestProtocolAdapter;ILapp/cash/redwood/ui/OnBackPressedDispatcher;Landroidx/compose/runtime/saveable/SaveableStateRegistry;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/jvm/functions/Function0;)Lapp/cash/redwood/compose/RedwoodComposition; - public static synthetic fun ProtocolRedwoodComposition-C-DY9sE$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/protocol/guest/GuestProtocolAdapter;ILapp/cash/redwood/ui/OnBackPressedDispatcher;Landroidx/compose/runtime/saveable/SaveableStateRegistry;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/redwood/compose/RedwoodComposition; + public static final fun ProtocolRedwoodComposition-9-Eitbk (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/protocol/guest/GuestProtocolAdapter;ILapp/cash/redwood/ui/OnBackPressedDispatcher;Landroidx/compose/runtime/saveable/SaveableStateRegistry;Lkotlinx/coroutines/flow/StateFlow;)Lapp/cash/redwood/compose/RedwoodComposition; } public abstract interface class app/cash/redwood/protocol/guest/ProtocolWidgetSystemFactory { diff --git a/redwood-protocol-guest/api/redwood-protocol-guest.klib.api b/redwood-protocol-guest/api/redwood-protocol-guest.klib.api index 9db367a6a1..8a23618f20 100644 --- a/redwood-protocol-guest/api/redwood-protocol-guest.klib.api +++ b/redwood-protocol-guest/api/redwood-protocol-guest.klib.api @@ -64,7 +64,7 @@ final val app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_Protoc final val app.cash.redwood.protocol.guest/guestRedwoodVersion // app.cash.redwood.protocol.guest/guestRedwoodVersion|{}guestRedwoodVersion[0] final fun (): app.cash.redwood.protocol/RedwoodVersion // app.cash.redwood.protocol.guest/guestRedwoodVersion.|(){}[0] -final fun app.cash.redwood.protocol.guest/ProtocolRedwoodComposition(kotlinx.coroutines/CoroutineScope, app.cash.redwood.protocol.guest/GuestProtocolAdapter, kotlin/UInt, app.cash.redwood.ui/OnBackPressedDispatcher, androidx.compose.runtime.saveable/SaveableStateRegistry?, kotlinx.coroutines.flow/StateFlow, kotlin/Function0 = ...): app.cash.redwood.compose/RedwoodComposition // app.cash.redwood.protocol.guest/ProtocolRedwoodComposition|ProtocolRedwoodComposition(kotlinx.coroutines.CoroutineScope;app.cash.redwood.protocol.guest.GuestProtocolAdapter;kotlin.UInt;app.cash.redwood.ui.OnBackPressedDispatcher;androidx.compose.runtime.saveable.SaveableStateRegistry?;kotlinx.coroutines.flow.StateFlow;kotlin.Function0){}[0] +final fun app.cash.redwood.protocol.guest/ProtocolRedwoodComposition(kotlinx.coroutines/CoroutineScope, app.cash.redwood.protocol.guest/GuestProtocolAdapter, kotlin/UInt, app.cash.redwood.ui/OnBackPressedDispatcher, androidx.compose.runtime.saveable/SaveableStateRegistry?, kotlinx.coroutines.flow/StateFlow): app.cash.redwood.compose/RedwoodComposition // app.cash.redwood.protocol.guest/ProtocolRedwoodComposition|ProtocolRedwoodComposition(kotlinx.coroutines.CoroutineScope;app.cash.redwood.protocol.guest.GuestProtocolAdapter;kotlin.UInt;app.cash.redwood.ui.OnBackPressedDispatcher;androidx.compose.runtime.saveable.SaveableStateRegistry?;kotlinx.coroutines.flow.StateFlow){}[0] final fun app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_DefaultGuestProtocolAdapter$stableprop_getter(): kotlin/Int // app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_DefaultGuestProtocolAdapter$stableprop_getter|app_cash_redwood_protocol_guest_DefaultGuestProtocolAdapter$stableprop_getter(){}[0] final fun app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_GuestProtocolAdapter$stableprop_getter(): kotlin/Int // app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_GuestProtocolAdapter$stableprop_getter|app_cash_redwood_protocol_guest_GuestProtocolAdapter$stableprop_getter(){}[0] final fun app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_ProtocolWidgetChildren$stableprop_getter(): kotlin/Int // app.cash.redwood.protocol.guest/app_cash_redwood_protocol_guest_ProtocolWidgetChildren$stableprop_getter|app_cash_redwood_protocol_guest_ProtocolWidgetChildren$stableprop_getter(){}[0] 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/api/redwood-treehouse-guest.api b/redwood-treehouse-guest/api/redwood-treehouse-guest.api index 7cefff4c04..1b2c0092cb 100644 --- a/redwood-treehouse-guest/api/redwood-treehouse-guest.api +++ b/redwood-treehouse-guest/api/redwood-treehouse-guest.api @@ -1,7 +1,6 @@ public final class app/cash/redwood/treehouse/StandardAppLifecycle : app/cash/redwood/treehouse/AppLifecycle { public synthetic fun (Lapp/cash/redwood/protocol/guest/ProtocolWidgetSystemFactory;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V - public final fun getFrameClock ()Landroidx/compose/runtime/MonotonicFrameClock; public fun getGuestProtocolVersion-7jYel6c ()Ljava/lang/String; public fun sendFrame (J)V public fun start (Lapp/cash/redwood/treehouse/AppLifecycle$Host;)V diff --git a/redwood-treehouse-guest/api/redwood-treehouse-guest.klib.api b/redwood-treehouse-guest/api/redwood-treehouse-guest.klib.api index 34ff8047eb..2ecd8dc684 100644 --- a/redwood-treehouse-guest/api/redwood-treehouse-guest.klib.api +++ b/redwood-treehouse-guest/api/redwood-treehouse-guest.klib.api @@ -9,8 +9,6 @@ final class app.cash.redwood.treehouse/StandardAppLifecycle : app.cash.redwood.treehouse/AppLifecycle { // app.cash.redwood.treehouse/StandardAppLifecycle|null[0] constructor (app.cash.redwood.protocol.guest/ProtocolWidgetSystemFactory, kotlinx.serialization.json/Json, kotlin/UInt) // app.cash.redwood.treehouse/StandardAppLifecycle.|(app.cash.redwood.protocol.guest.ProtocolWidgetSystemFactory;kotlinx.serialization.json.Json;kotlin.UInt){}[0] - final val frameClock // app.cash.redwood.treehouse/StandardAppLifecycle.frameClock|{}frameClock[0] - final fun (): androidx.compose.runtime/MonotonicFrameClock // app.cash.redwood.treehouse/StandardAppLifecycle.frameClock.|(){}[0] final val guestProtocolVersion // app.cash.redwood.treehouse/StandardAppLifecycle.guestProtocolVersion|{}guestProtocolVersion[0] final fun (): app.cash.redwood.protocol/RedwoodVersion // app.cash.redwood.treehouse/StandardAppLifecycle.guestProtocolVersion.|(){}[0] 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..6382c8e3af 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,17 @@ private class RedwoodZiplineTreehouseUi( private val guestAdapter: GuestProtocolAdapter, ) : ZiplineTreehouseUi, ZiplineScoped, - EventSink by guestAdapter { + EventSink by guestAdapter, + StandardAppLifecycle.FrameListener { + + private val clock = BroadcastFrameClock { + appLifecycle.requestHostFrame() + } + + 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 +136,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 +149,10 @@ 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. + guestAdapter.emitChanges() } override fun snapshotState(): StateSnapshot { @@ -145,6 +161,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) {