From 471b273c905e349c0ba069538617c5b952a1c2ae Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Mon, 23 Jun 2025 17:30:15 -0700 Subject: [PATCH 01/13] WIP Add a config to use Compose for the runtime. See go/compose-based-workflows. --- .../PerformanceTracingInterceptor.kt | 10 +- .../WorkflowRuntimeMicrobenchmark.kt | 12 + build.gradle.kts | 1 + dependencies/classpath.txt | 2 + gradle/libs.versions.toml | 6 + .../NestedRenderingsActivity.kt | 5 +- .../sample/poetryapp/PoetryActivity.kt | 3 +- .../squareup/sample/ravenapp/RavenActivity.kt | 3 +- .../HelloBackButtonActivity.kt | 3 +- samples/dungeon/app/build.gradle.kts | 4 + .../com/squareup/sample/dungeon/Component.kt | 7 +- .../sample/dungeon/DungeonActivity.kt | 9 +- .../squareup/sample/dungeon/SimpleWorkflow.kt | 61 +++ .../sample/dungeon/TimeMachineAppWorkflow.kt | 1 + .../sample/dungeon/TimeMachineModel.kt | 8 +- .../squareup/sample/timemachine/TimeSeries.kt | 3 + .../sample/mainactivity/TicTacToeModel.kt | 5 +- workflow-core/api/workflow-core.api | 3 + .../com/squareup/workflow1/RuntimeConfig.kt | 13 +- .../com/squareup/workflow1/WorkflowTracer.kt | 20 + .../api/android/workflow-runtime.api | 20 + workflow-runtime/api/jvm/workflow-runtime.api | 20 + workflow-runtime/build.gradle.kts | 26 +- .../runtime/GlobalSnapshotManager.android.kt | 8 + .../com/squareup/workflow1/RenderWorkflow.kt | 14 + .../SimpleLoggingWorkflowInterceptor.kt | 16 +- .../squareup/workflow1/WorkflowInterceptor.kt | 10 + .../squareup/workflow1/internal/Channels.kt | 20 + .../workflow1/internal/WorkflowNode.kt | 11 +- .../internal/compose/ComposeRenderContext.kt | 348 ++++++++++++++ .../compose/ComposeWorkflowInterceptor.kt | 55 +++ .../internal/compose/CompositionLocals.kt | 26 ++ .../internal/compose/PeekableMutableState.kt | 45 ++ .../internal/compose/RememberComposable.kt | 433 ++++++++++++++++++ .../internal/compose/RenderWorkflow.kt | 68 +++ .../RenderWorkflowWithComposeRuntime.kt | 231 ++++++++++ .../workflow1/internal/compose/TraceLabels.kt | 10 + .../workflow1/internal/compose/Trapdoor.kt | 139 ++++++ .../WorkflowComposableRuntimeConfig.kt | 46 ++ .../internal/compose/WorkflowSnapshotSaver.kt | 36 ++ .../compose/runtime/GlobalSnapshotManager.kt | 66 +++ .../compose/runtime/SynchronizedMolecule.kt | 332 ++++++++++++++ .../internal/compose/runtime/UnitApplier.kt | 42 ++ .../workflow1/RenderWorkflowInTest.kt | 412 ++++++++++------- .../runtime/GlobalSnapshotManager.ios.kt | 7 + .../runtime/GlobalSnapshotManager.js.kt | 6 + .../runtime/GlobalSnapshotManager.jvm.kt | 7 + .../SerializableSaveableStateRegistry.kt | 72 +++ ...WorkflowRuntimeMultithreadingStressTest.kt | 45 +- workflow-ui/core-common/api/core-common.api | 1 + .../workflow1/ui/EnvironmentScreen.kt | 7 + 51 files changed, 2543 insertions(+), 215 deletions(-) create mode 100644 samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/SimpleWorkflow.kt create mode 100644 workflow-runtime/src/androidMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.android.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraceLabels.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/Trapdoor.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowComposableRuntimeConfig.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaver.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt create mode 100644 workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt create mode 100644 workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt create mode 100644 workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt create mode 100644 workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt index 4b0756915c..fe71bbda5f 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt @@ -3,6 +3,7 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation import androidx.tracing.Trace import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow +import com.squareup.benchmarks.performance.complex.poetry.instrumentation.PerformanceTracingInterceptor.Companion.NODES_TO_TRACE import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor @@ -27,6 +28,13 @@ class PerformanceTracingInterceptor( context: BaseRenderContext, proceed: (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession + ): R = traceRender(session) { + proceed(renderProps, renderState, null) + } + + private inline fun traceRender( + session: WorkflowSession, + render: () -> R ): R { val isRoot = session.parent == null val traceIdIndex = NODES_TO_TRACE.indexOfFirst { it.second == session.identifier } @@ -45,7 +53,7 @@ class PerformanceTracingInterceptor( Trace.beginSection(sectionName) } - return proceed(renderProps, renderState, null).also { + return render().also { if (traceIdIndex > -1 && !sample) { Trace.endSection() } diff --git a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt index 2f998d4648..be9510dfeb 100644 --- a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt +++ b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt @@ -16,6 +16,7 @@ import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.WorkflowTracer import com.squareup.workflow1.action +import com.squareup.workflow1.internal.compose.runtime.setGlobalSnapshotManagerSendApplyImmediately import com.squareup.workflow1.remember import com.squareup.workflow1.renderChild import com.squareup.workflow1.renderWorkflowIn @@ -25,6 +26,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.job import kotlinx.coroutines.plus import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.math.pow @@ -38,6 +41,7 @@ enum class BenchmarkRuntimeOptions( ) { NoOptimizations(RuntimeOptions.NONE.runtimeConfig), AllOptimizations(RuntimeOptions.ALL.runtimeConfig), + Compose(RuntimeOptions.COMPOSE_RUNTIME_ONLY.runtimeConfig), } enum class BenchmarkTreeShape( @@ -63,6 +67,14 @@ class WorkflowRuntimeMicrobenchmark( @get:Rule val benchmarkRule = BenchmarkRule() + @Before fun setUp() { + setGlobalSnapshotManagerSendApplyImmediately(true) + } + + @After fun tearDown() { + setGlobalSnapshotManagerSendApplyImmediately(false) + } + @Test fun initialRenderAllChildren() = benchmarkWorkflowPropsChange( setupProps = BenchmarkWorkflowRoot.Props( renderFirstLeaf = false, diff --git a/build.gradle.kts b/build.gradle.kts index 3a031f53fe..05db068feb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,7 @@ plugins { alias(libs.plugins.ktlint) alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.androidx.benchmark) apply false + alias(libs.plugins.jetbrains.compose) apply false } shardConnectedCheckTasks(project) diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index 6859200b4f..daddcdd3f6 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -141,6 +141,8 @@ org.codehaus.woodstox:stax2-api:4.2.1 org.glassfish.jaxb:jaxb-runtime:2.3.2 org.glassfish.jaxb:txw2:2.3.2 org.jdom:jdom2:2.0.6 +org.jetbrains.compose:compose-gradle-plugin:1.7.3 +org.jetbrains.compose:org.jetbrains.compose.gradle.plugin:1.7.3 org.jetbrains.dokka:dokka-core:2.0.0 org.jetbrains.dokka:dokka-gradle-plugin:2.0.0 org.jetbrains.dokka:org.jetbrains.dokka.gradle.plugin:2.0.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04c83d1972..000d82870e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -117,6 +117,8 @@ kotlinx-apiBinaryCompatibility = { id = "org.jetbrains.kotlinx.binary-compatibil mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose-plugin" } +android-library = { id = "com.android.library", version.ref = "agpVersion" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } [libraries] @@ -209,7 +211,11 @@ hamcrest = "org.hamcrest:hamcrest-core:2.2" java-diff-utils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "java-diff-utils" } telephoto = { module = "me.saket.telephoto:zoomable", version.ref = "telephoto" } + jetbrains-annotations = "org.jetbrains:annotations:24.0.1" +jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrains-compose-plugin" } +jetbrains-compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "jetbrains-compose-plugin" } +jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrains-compose-plugin" } junit = { module = "junit:junit", version.ref = "jUnit" } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt index ef79fa7a74..1c58e67a5a 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME +import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -49,7 +51,8 @@ class NestedRenderingsActivity : AppCompatActivity() { workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) }, scope = viewModelScope + AndroidUiDispatcher.Main, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = setOf(COMPOSE_RUNTIME), // AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + interceptors = listOf(SimpleLoggingWorkflowInterceptor()), ) } } diff --git a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt index f0b555883c..5c2b956c5b 100644 --- a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt +++ b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt @@ -12,6 +12,7 @@ import com.squareup.sample.container.SampleContainers import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.RealPoemsBrowserWorkflow import com.squareup.sample.poetry.model.Poem +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -47,7 +48,7 @@ class PoetryModel(savedState: SavedStateHandle) : ViewModel() { scope = viewModelScope, prop = 0 to 0 to Poem.allPoems, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = setOf(COMPOSE_RUNTIME), // AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() ).reportNavigation { Timber.i("Navigated to %s", it) } diff --git a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt index e6900be1ae..fb68a556c0 100644 --- a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt +++ b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import com.squareup.sample.container.SampleContainers import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.model.Raven +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -56,7 +57,7 @@ class RavenModel(savedState: SavedStateHandle) : ViewModel() { scope = viewModelScope, savedStateHandle = savedState, prop = Raven, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = setOf(COMPOSE_RUNTIME), // AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() ) { running.complete() }.reportNavigation { diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt index 76ef950524..20062eeb30 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import com.squareup.sample.container.SampleContainers +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -53,7 +54,7 @@ class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() { workflow = AreYouSureWorkflow, scope = viewModelScope, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = setOf(COMPOSE_RUNTIME), // AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() ) { // This workflow handles the back button itself, so the activity can't. // Instead, the workflow emits an output to signal that it's time to shut things down. diff --git a/samples/dungeon/app/build.gradle.kts b/samples/dungeon/app/build.gradle.kts index 1bdc085446..96358323d6 100644 --- a/samples/dungeon/app/build.gradle.kts +++ b/samples/dungeon/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") id("android-sample-app") id("android-ui-tests") + alias(libs.plugins.compose.compiler) } android { @@ -43,12 +44,15 @@ dependencies { implementation(libs.rxjava2.rxandroid) implementation(libs.squareup.cycler) implementation(libs.squareup.okio) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.foundation) implementation(project(":samples:dungeon:common")) implementation(project(":samples:dungeon:timemachine")) implementation(project(":samples:dungeon:timemachine-shakeable")) implementation(project(":workflow-ui:core-android")) implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:compose")) testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt index f37ef0a203..02a0a91b42 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/Component.kt @@ -52,7 +52,12 @@ class Component(context: AppCompatActivity) { val appWorkflow = DungeonAppWorkflow(gameSessionWorkflow, boardLoader) - val timeMachineWorkflow = TimeMachineAppWorkflow(appWorkflow, clock, context) + val timeMachineWorkflow = TimeMachineAppWorkflow( + appWorkflow, + // SimpleWorkflow(), + clock, + context + ) val timeMachineModelFactory = TimeMachineModel.Factory( context, diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt index 9757a9bc97..342a71981b 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt @@ -3,6 +3,8 @@ package com.squareup.sample.dungeon import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import com.squareup.workflow1.ui.compose.withComposeInteropSupport +import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.withRegistry import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.map @@ -17,6 +19,11 @@ class DungeonActivity : AppCompatActivity() { val model: TimeMachineModel by viewModels { component.timeMachineModelFactory } workflowContentView - .take(lifecycle, model.renderings.map { it.withRegistry(component.viewRegistry) }) + .take( + lifecycle, + model.renderings.map { + it.withRegistry(component.viewRegistry) + .withEnvironment { it.withComposeInteropSupport() } + }) } } diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/SimpleWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/SimpleWorkflow.kt new file mode 100644 index 0000000000..7c36a3e7a0 --- /dev/null +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/SimpleWorkflow.kt @@ -0,0 +1,61 @@ +package com.squareup.sample.dungeon + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.squareup.sample.dungeon.DungeonAppWorkflow.Props +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.parse +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.compose.ComposeScreen + +class SimpleWorkflow : StatefulWorkflow() { + override fun initialState( + props: Props, + snapshot: Snapshot? + ): String = snapshot?.bytes?.parse { it.readUtf8() } ?: "initial" + + override fun render( + renderProps: Props, + renderState: String, + context: RenderContext + ): Screen { + return MyScreen( + text = renderState, + onClick = context.eventHandler("onClick", remember = true) { state = state.reversed() } + ) + } + + override fun snapshotState(state: String): Snapshot = Snapshot.of(state) +} + +private data class MyScreen( + val text: String, + val onClick: () -> Unit, +) : ComposeScreen { + @Composable override fun Content() { + Modifier.pointerInput(Unit) { + extendedTouchPadding + } + BasicText( + text = text, + color = { Color.White }, + modifier = Modifier + .background(Color.White) + .wrapContentSize() + .padding(8.dp) + .clickable { onClick() } + .background(Color(red = 0f, green = 0f, blue = 0.9f)) + .padding(48.dp) + ) + } +} diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineAppWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineAppWorkflow.kt index 339ffdcf5c..16c9fec4af 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineAppWorkflow.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineAppWorkflow.kt @@ -18,6 +18,7 @@ import kotlin.time.TimeSource @OptIn(ExperimentalTime::class) class TimeMachineAppWorkflow( appWorkflow: DungeonAppWorkflow, + // appWorkflow: SimpleWorkflow, clock: TimeSource, context: Context ) : StatelessWorkflow() { diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt index f3b0932a88..0bb8ede360 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt @@ -5,9 +5,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME +import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn -import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import kotlinx.coroutines.flow.StateFlow import kotlin.time.ExperimentalTime @@ -24,8 +25,9 @@ class TimeMachineModel( prop = "simple_maze.txt", scope = viewModelScope, savedStateHandle = savedState, - interceptors = emptyList(), - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + interceptors = listOf(SimpleLoggingWorkflowInterceptor()), + // runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), + runtimeConfig = setOf(COMPOSE_RUNTIME), ) } diff --git a/samples/dungeon/timemachine/src/main/java/com/squareup/sample/timemachine/TimeSeries.kt b/samples/dungeon/timemachine/src/main/java/com/squareup/sample/timemachine/TimeSeries.kt index 2d0a833e9e..ed668810eb 100644 --- a/samples/dungeon/timemachine/src/main/java/com/squareup/sample/timemachine/TimeSeries.kt +++ b/samples/dungeon/timemachine/src/main/java/com/squareup/sample/timemachine/TimeSeries.kt @@ -78,4 +78,7 @@ internal class TimeSeries( else -> data[leftIndex] }.first } + + override fun toString(): String = + "TimeSeries(size=${data.size}, duration=$duration, last=${data.lastOrNull()})" } diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt index db523f643d..98c4acec82 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt @@ -8,6 +8,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.squareup.sample.mainworkflow.TicTacToeWorkflow +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -28,7 +31,7 @@ class TicTacToeModel( scope = viewModelScope, savedStateHandle = savedState, interceptors = emptyList(), - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = setOf(RuntimeConfigOptions.COMPOSE_RUNTIME) //AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() ) { running.complete() } diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 83471185e1..e2aa6b69cc 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -163,6 +163,7 @@ public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ } public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { + public static final field COMPOSE_RUNTIME Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; public static final field DRAIN_EXCLUSIVE_ACTIONS Lcom/squareup/workflow1/RuntimeConfigOptions; @@ -184,6 +185,7 @@ public final class com/squareup/workflow1/RuntimeConfigOptions$Companion { public final class com/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions : java/lang/Enum { public static final field ALL Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field COMPOSE_RUNTIME_ONLY Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; public static final field CONFLATE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; public static final field CONFLATE_DRAIN Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; public static final field CONFLATE_DRAIN_STEAL Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; @@ -487,6 +489,7 @@ public abstract interface class com/squareup/workflow1/WorkflowTracer { public final class com/squareup/workflow1/WorkflowTracerKt { public static final fun trace (Lcom/squareup/workflow1/WorkflowTracer;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; + public static final fun traceNoFinally (Lcom/squareup/workflow1/WorkflowTracer;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; } public final class com/squareup/workflow1/Workflows { diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt index 566b8478d6..cbed3c1202 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -113,6 +113,12 @@ public enum class RuntimeConfigOptions { */ @WorkflowExperimentalRuntime INDEXED_ACTIVE_STAGING_LISTS, + + /** + * Replaces the traditional Workflow runtime with the Compose runtime. + */ + @WorkflowExperimentalRuntime + COMPOSE_RUNTIME, ; public companion object { @@ -125,10 +131,11 @@ public enum class RuntimeConfigOptions { public val DEFAULT_CONFIG: RuntimeConfig = RENDER_PER_ACTION /** - * Configuration that enables every [RuntimeConfig] option. + * Configuration that enables every [RuntimeConfig] option for the traditional (non-Compose) + * runtime. */ @WorkflowExperimentalRuntime - public val ALL: RuntimeConfig = entries.toSet() + public val ALL: RuntimeConfig = entries.toSet() - COMPOSE_RUNTIME /** * Enum of all reasonable config options. Used especially for parameterized testing. @@ -474,6 +481,8 @@ public enum class RuntimeConfigOptions { ) ), + COMPOSE_RUNTIME_ONLY(setOf(RuntimeConfigOptions.COMPOSE_RUNTIME)), + /** * Always contains all [RuntimeConfigOptions]. Other values in this enum may happen to contain * the same set at some point in time, but this one will also always be updated to include new diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowTracer.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowTracer.kt index 49dd279f45..1d74eff8e3 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowTracer.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowTracer.kt @@ -1,5 +1,9 @@ package com.squareup.workflow1 +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + /** * This is a very simple tracing interface that can be passed into a workflow runtime in order * to inject span tracing throughout the workflow core and runtime internals. @@ -14,10 +18,12 @@ public interface WorkflowTracer { * wraps very frequently evaluated code and we should only use constants for [label], with no * interpolation. */ +@OptIn(ExperimentalContracts::class) public inline fun WorkflowTracer?.trace( label: String, block: () -> T ): T { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return if (this == null) { block() } else { @@ -29,3 +35,17 @@ public inline fun WorkflowTracer?.trace( } } } + +/** + * Like [trace] but _never_ wraps with a try/catch and doesn't branch, making it safe to use from + * Composable functions. + */ +public inline fun WorkflowTracer?.traceNoFinally( + label: String, + block: () -> T +): T { + this?.beginSection(label) + val result = block() + this?.endSection() + return result +} diff --git a/workflow-runtime/api/android/workflow-runtime.api b/workflow-runtime/api/android/workflow-runtime.api index 4a592e5395..8cd49e479f 100644 --- a/workflow-runtime/api/android/workflow-runtime.api +++ b/workflow-runtime/api/android/workflow-runtime.api @@ -1,4 +1,5 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/NoopWorkflowInterceptor; public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -17,6 +18,7 @@ public final class com/squareup/workflow1/RenderWorkflowKt { } public final class com/squareup/workflow1/RenderingAndSnapshot { + public static final field $stable I public fun (Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Lcom/squareup/workflow1/TreeSnapshot; @@ -25,6 +27,7 @@ public final class com/squareup/workflow1/RenderingAndSnapshot { } public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public fun ()V protected fun log (Ljava/lang/String;)V protected fun logAfterMethod (Ljava/lang/String;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;[Lkotlin/Pair;)V @@ -42,6 +45,7 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar } public final class com/squareup/workflow1/TreeSnapshot { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/TreeSnapshot$Companion; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -93,6 +97,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderContextInter } public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -100,6 +105,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped } public final class com/squareup/workflow1/WorkflowInterceptor$RenderingConflated : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderingConflated; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -107,6 +113,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderingConflated } public final class com/squareup/workflow1/WorkflowInterceptor$RenderingProduced : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderingProduced; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -114,6 +121,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderingProduced } public final class com/squareup/workflow1/WorkflowInterceptor$RuntimeSettled : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeSettled; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -157,3 +165,15 @@ public final class com/squareup/workflow1/internal/Throwables_jvmKt { public static final fun withKey (Ljava/lang/Throwable;Ljava/lang/Object;)Ljava/lang/Throwable; } +public final class com/squareup/workflow1/internal/compose/RememberComposableKt { + public static final fun rSKC_after (Ljava/lang/Object;Landroidx/compose/runtime/Composer;)Ljava/lang/Object; + public static final fun rSKC_before (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/runtime/Composer;I)Z + public static synthetic fun rSKC_before$default (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/runtime/Composer;IILjava/lang/Object;)Z + public static final fun rememberSkippableAndRestartableComposableImpl (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/RecomposeScope;Landroidx/compose/runtime/Composer;IZ)Ljava/lang/Object; + public static final fun rememberSkippableComposableImpl (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManagerKt { + public static final fun setGlobalSnapshotManagerSendApplyImmediately (Z)V +} + diff --git a/workflow-runtime/api/jvm/workflow-runtime.api b/workflow-runtime/api/jvm/workflow-runtime.api index e707b7fe9b..5191248bad 100644 --- a/workflow-runtime/api/jvm/workflow-runtime.api +++ b/workflow-runtime/api/jvm/workflow-runtime.api @@ -1,4 +1,5 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/NoopWorkflowInterceptor; public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -17,6 +18,7 @@ public final class com/squareup/workflow1/RenderWorkflowKt { } public final class com/squareup/workflow1/RenderingAndSnapshot { + public static final field $stable I public fun (Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Lcom/squareup/workflow1/TreeSnapshot; @@ -25,6 +27,7 @@ public final class com/squareup/workflow1/RenderingAndSnapshot { } public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public fun ()V protected fun log (Ljava/lang/String;)V protected fun logAfterMethod (Ljava/lang/String;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;[Lkotlin/Pair;)V @@ -42,6 +45,7 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar } public final class com/squareup/workflow1/TreeSnapshot { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/TreeSnapshot$Companion; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -93,6 +97,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderContextInter } public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -100,6 +105,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped } public final class com/squareup/workflow1/WorkflowInterceptor$RenderingConflated : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderingConflated; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -107,6 +113,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderingConflated } public final class com/squareup/workflow1/WorkflowInterceptor$RenderingProduced : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderingProduced; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -114,6 +121,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderingProduced } public final class com/squareup/workflow1/WorkflowInterceptor$RuntimeSettled : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeSettled; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I @@ -147,3 +155,15 @@ public final class com/squareup/workflow1/internal/Throwables_jvmKt { public static final fun withKey (Ljava/lang/Throwable;Ljava/lang/Object;)Ljava/lang/Throwable; } +public final class com/squareup/workflow1/internal/compose/RememberComposableKt { + public static final fun rSKC_after (Ljava/lang/Object;Landroidx/compose/runtime/Composer;)Ljava/lang/Object; + public static final fun rSKC_before (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/runtime/Composer;I)Z + public static synthetic fun rSKC_before$default (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/runtime/Composer;IILjava/lang/Object;)Z + public static final fun rememberSkippableAndRestartableComposableImpl (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/RecomposeScope;Landroidx/compose/runtime/Composer;IZ)Ljava/lang/Object; + public static final fun rememberSkippableComposableImpl (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManagerKt { + public static final fun setGlobalSnapshotManagerSendApplyImmediately (Z)V +} + diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index 75cb95daad..eefb1ef392 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -1,5 +1,6 @@ import com.android.build.api.dsl.androidLibrary import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType plugins { // This is the new/future plugin for Android in KMP. com.android.library is going away. @@ -9,6 +10,20 @@ plugins { id("kotlin-multiplatform") id("published") id("app.cash.burst") + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.compose.compiler) +} + +// Configure dependency resolution to prefer desktop variants for JVM target. +// Only resolvable configurations can have attributes set; Gradle 9.x rejects attribute +// assignment on declarable-only configurations (e.g. *ApiElements-published). +configurations.configureEach { + if (isCanBeResolved) { + attributes { + // When resolving for JVM, prefer the desktop (non-Android) variants of Compose + attribute(KotlinPlatformType.attribute, KotlinPlatformType.jvm) + } + } } kotlin { @@ -49,6 +64,10 @@ kotlin { dependencies { api(project(":workflow-core")) api(libs.kotlinx.coroutines.core) + + // These become aliases to the androidx runtime libraries in Compose 1.9.3. + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.runtime.saveable) } } @@ -70,12 +89,13 @@ kotlin { // Add Android-specific dependencies here. Note that this source set depends on // commonMain by default and will correctly pull the Android artifacts of any KMP // dependencies declared in commonMain. - val composeBom = project.dependencies.platform(libs.androidx.compose.bom) + // val composeBom = project.dependencies.platform(libs.androidx.compose.bom) - api(libs.androidx.compose.ui.android) + // api(libs.androidx.compose.ui.android) + implementation(libs.jetbrains.compose.ui) api(libs.androidx.lifecycle.viewmodel.savedstate) - api(composeBom) + // api(composeBom) } } diff --git a/workflow-runtime/src/androidMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.android.kt b/workflow-runtime/src/androidMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.android.kt new file mode 100644 index 0000000000..6886267700 --- /dev/null +++ b/workflow-runtime/src/androidMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.android.kt @@ -0,0 +1,8 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.ui.platform.AndroidUiDispatcher +import kotlinx.coroutines.CoroutineDispatcher + +@OptIn(ExperimentalStdlibApi::class) +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = AndroidUiDispatcher.Main[CoroutineDispatcher]!! diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index deb090ca2e..6fbdbc87cb 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES @@ -10,6 +11,7 @@ import com.squareup.workflow1.WorkflowInterceptor.RuntimeSettled import com.squareup.workflow1.internal.WorkStealingDispatcher import com.squareup.workflow1.internal.WorkflowRunner import com.squareup.workflow1.internal.chained +import com.squareup.workflow1.internal.compose.renderWorkflowWithComposeRuntimeIn import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -151,6 +153,18 @@ public fun renderWorkflowIn( ): StateFlow> { val chainedInterceptor = interceptors.chained() + if (COMPOSE_RUNTIME in runtimeConfig) { + return renderWorkflowWithComposeRuntimeIn( + workflow = workflow, + scope = scope, + props = props, + initialSnapshot = initialSnapshot, + interceptor = chainedInterceptor, + workflowTracer = workflowTracer, + onOutput = onOutput, + ) + } + val dispatcher = if (RuntimeConfigOptions.WORK_STEALING_DISPATCHER in runtimeConfig) { WorkStealingDispatcher.wrapDispatcherFrom(scope.coroutineContext) } else { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 95b38f0cf0..c2248625e1 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt @@ -2,6 +2,9 @@ package com.squareup.workflow1 import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.internal.getValue +import com.squareup.workflow1.internal.setValue +import com.squareup.workflow1.internal.threadLocalOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -9,6 +12,8 @@ import kotlinx.coroutines.CoroutineScope * A [WorkflowInterceptor] that just prints all method calls using [log]. */ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { + private var indentLevel by threadLocalOf { 0 } + override fun onSessionStarted( workflowScope: CoroutineScope, session: WorkflowSession @@ -68,8 +73,11 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { vararg extras: Pair, block: () -> T ): T { + val currentIndentLevel = indentLevel invokeSafely("logBeforeMethod") { logBeforeMethod(name, session, *extras) } + indentLevel = currentIndentLevel + 1 return block().also { + indentLevel = currentIndentLevel invokeSafely("logAfterMethod") { logAfterMethod(name, session, *extras) } } } @@ -96,9 +104,9 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { protected open fun logBeforeMethod( name: String, session: WorkflowSession, - vararg extras: Pair + vararg extras: Pair, ) { - log("START| ${formatLogMessage(name, session, extras)}") + log("START| ${" ".repeat(indentLevel)}${formatLogMessage(name, session, extras)}") } /** @@ -107,9 +115,9 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { protected open fun logAfterMethod( name: String, session: WorkflowSession, - vararg extras: Pair + vararg extras: Pair, ) { - log(" END| ${formatLogMessage(name, session, extras)}") + log(" END| ${" ".repeat(indentLevel)}${formatLogMessage(name, session, extras)}") } /** diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 63bff1ad99..fa9bdb5552 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -497,3 +497,13 @@ private class InterceptedRenderContext( return coroutineContext } } + +internal fun WorkflowSession.workflowSessionToString(): String { + val parentDescription = parent?.let { "WorkflowInstance(…)" } + return "WorkflowInstance(" + + "identifier=$identifier, " + + "renderKey=$renderKey, " + + "instanceId=$sessionId, " + + "parent=$parentDescription" + + ")" +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt new file mode 100644 index 0000000000..ce32c89669 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.internal + +import kotlinx.coroutines.channels.SendChannel + +/** + * Tries to send [element] to this channel and throws an [IllegalStateException] if the channel is + * full or closed. + */ +internal fun SendChannel.requireSend(element: T) { + val result = trySend(element) + if (result.isClosed) { + throw IllegalStateException( + "Tried emitting output to workflow whose output channel was closed.", + result.exceptionOrNull() + ) + } + if (result.isFailure) { + error("Tried emitting output to workflow whose output channel was full.") + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index 818638ad47..64ea642119 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -24,6 +24,7 @@ import com.squareup.workflow1.applyTo import com.squareup.workflow1.intercept import com.squareup.workflow1.internal.RealRenderContext.RememberStore import com.squareup.workflow1.internal.RealRenderContext.SideEffectRunner +import com.squareup.workflow1.workflowSessionToString import com.squareup.workflow1.trace import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName @@ -141,15 +142,7 @@ internal class WorkflowNode( state = interceptedWorkflowInstance.initialState(initialProps, snapshot?.workflowSnapshot, this) } - override fun toString(): String { - val parentDescription = parent?.let { "WorkflowInstance(…)" } - return "WorkflowInstance(" + - "identifier=$identifier, " + - "renderKey=$renderKey, " + - "instanceId=$sessionId, " + - "parent=$parentDescription" + - ")" - } + override fun toString(): String = workflowSessionToString() /** * Walk the tree of workflows, rendering each one and using diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt new file mode 100644 index 0000000000..3dc1a44d11 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt @@ -0,0 +1,348 @@ +@file:OptIn(WorkflowExperimentalApi::class) + +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.ExplicitGroupsComposable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.RenderContext +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowIdentifier +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.applyTo +import com.squareup.workflow1.identifier +import com.squareup.workflow1.intercept +import com.squareup.workflow1.internal.Lock +import com.squareup.workflow1.internal.compose.TraceLabels.InitialState +import com.squareup.workflow1.internal.compose.TraceLabels.OnPropsChanged +import com.squareup.workflow1.internal.compose.TraceLabels.SendAction +import com.squareup.workflow1.internal.createId +import com.squareup.workflow1.internal.getValue +import com.squareup.workflow1.internal.setValue +import com.squareup.workflow1.internal.threadLocalOf +import com.squareup.workflow1.internal.withLock +import com.squareup.workflow1.trace +import com.squareup.workflow1.traceNoFinally +import com.squareup.workflow1.workflowSessionToString +import kotlinx.coroutines.CoroutineScope +import kotlin.concurrent.Volatile +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KType + +// TODO tests for all these methods keeping state when order changes. Make sure to exercise all keys +// passed as data keys keep state, and all keys _not_ passed as data keys reset state when changed +// but aren't interchangable relative to data keys. +@OptIn(ExperimentalComposeRuntimeApi::class) +@Stable +internal class ComposeRenderContext private constructor( + private val workflow: Workflow, + snapshot: Snapshot?, + initialProps: P, + private val workflowScope: CoroutineScope, + private val recomposeScope: RecomposeScope, + private val config: WorkflowComposableRuntimeConfig, + override val parent: WorkflowSession?, + override val renderKey: String, +) : BaseRenderContext, + Sink>, + WorkflowSession { + + private val interceptedWorkflow: StatefulWorkflow + private val state: PeekableMutableState + private val applyActionLock = Lock() + private var trapdoor: Trapdoor? by threadLocalOf { null } + + // Render params from last render pass that are only used by actions, and don't need to be states. + @Volatile private var onOutput: ((O) -> Unit)? = null + @Volatile private var lastProps = initialProps + + override val actionSink: Sink> get() = this + + // region WorkflowSession implementation + override val identifier: WorkflowIdentifier get() = workflow.identifier + override val sessionId: Long = config.idCounter.createId() + override val runtimeConfig: RuntimeConfig get() = config.runtimeConfig + override val runtimeContext: CoroutineContext get() = workflowScope.coroutineContext + override val workflowTracer: WorkflowTracer? get() = config.workflowTracer + override fun toString(): String = workflowSessionToString() + // endregion + + init { + @Suppress("UNCHECKED_CAST") + val statefulWorkflow = workflow.asStatefulWorkflow() as StatefulWorkflow + val interceptor = config.workflowInterceptor + + interceptor?.onSessionStarted( + workflowScope = workflowScope, + session = this, + ) + + interceptedWorkflow = + interceptor?.intercept(workflow = statefulWorkflow, workflowSession = this) + ?: statefulWorkflow + + val initialState = config.workflowTracer.trace(InitialState) { + interceptedWorkflow.initialState( + props = initialProps, + snapshot = snapshot, + workflowScope = workflowScope, + ) + } + state = PeekableMutableState(initialState) + } + + @Composable + fun renderSelf(props: P): R = withTrapdoor { + interceptedWorkflow.render( + renderProps = props, + renderState = state.value, + context = RenderContext(this, interceptedWorkflow) + ) + } + + override fun send(value: WorkflowAction) { + workflowTracer.trace(SendAction) { + applyAction(value) + } + } + + override fun runningSideEffect( + key: String, + sideEffect: suspend CoroutineScope.() -> Unit + ) { + // We pass the key as the movable data key instead of just passing it to LaunchedEffect since + // we want this group to be movable, not just restartable. + requireTrapdoor("runningSideEffect").inMovableGroup(GROUP_KEY, key) { + // Can't use sideEffect as key since it is allocated anew every render pass, so it will always + // be different. + LaunchedEffect(Unit, block = sideEffect) + } + } + + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT = requireTrapdoor("remember").inMovableGroup(GROUP_KEY, key) { + // RememberStore keys the lifetime of the memoization off the result type and inputs in addition + // to the explicit key. The data key is only used to keep the memoized state consistent as the + // order and number of calls to this context change. The resultType and inputs only need to + // reset the state for this particular remember call, so they don't need to be part of the data + // key. We use a separate key call since it's simpler than correctly folding multiple objects + // into a single data key (which requires calling Composer.joinKey). + key(resultType, *inputs) { + remember(*inputs, calculation = calculation) + } + } + + override fun renderChild( + child: Workflow, + props: ChildPropsT, + key: String, + handler: (ChildOutputT) -> WorkflowAction + ): ChildRenderingT = requireTrapdoor("renderChild") + // child.identifier is usually cached by Workflow so calling it on recomposition is cheap. + .inMovableGroup(GROUP_KEY, child.identifier, key) { + // Memoize the onOutput function so that renderChild can skip whenever possible. + val updatedHandler by rememberUpdatedState(handler) + val childOnOutput: (ChildOutputT) -> Unit = remember { + { output -> + val action = updatedHandler(output) + applyAction(action) + } + } + + renderWorkflow( + workflow = child, + props = props, + onOutput = childOnOutput, + config = config, + parentSession = this, + renderKey = key + ) + } + + private fun requireTrapdoor(operationName: String): Trapdoor = + trapdoor ?: error("Cannot perform $operationName on RenderContext outside of render pass.") + + // Since we need to manually deal with props diffing ourselves, tell compose not to generate its + // own checking code which would result in redundant .equals() calls. + @Suppress("ComposableNaming") + @NonRestartableComposable + @Composable + private fun updatePropsAndOutput( + props: P, + onOutput: ((O) -> Unit)?, + ) { + // Use the slot table to detect props changes. + Trapdoor.runIfValueChanged(props) { oldProps -> + workflowTracer.traceNoFinally(OnPropsChanged) { + @Suppress("UNCHECKED_CAST") + state.value = interceptedWorkflow.onPropsChanged( + old = oldProps, + new = props, + state = state.value + ) + } + } + SideEffect { + this.lastProps = props + this.onOutput = onOutput + } + } + + private fun onDisposed() { + config.workflowInterceptor?.onSessionCancelled( + cause = null, + // Compose workflow node actions are applied immediately so there can never be actions pending + // when a workflow is disposed. + droppedActions = emptyList(), + session = this + ) + } + + private fun applyAction(action: WorkflowAction) { + check(trapdoor == null) { "Cannot send to action sink from render pass." } + // Send can be called from any thread so wrap non-atomic reads/writes in a critical section. + applyActionLock.withLock { + val oldState = state.value + val (newState, applied) = action.applyTo(lastProps, oldState) + state.setWithInvalidator(newState, invalidator = { recomposeScope.invalidate() }) + + // Propagate the output up the workflow tree. Propagation doesn't touch our state but still + // should be part of the critical section: For an intermediate node, while it's propagating + // an action from one part of its subtree, if another action comes in from a different part + // of the subtree, the second action won't propagate until the first one is done. If we + // moved this out of the lock, then the propagations of two actions from two subtrees could + // be interleaved to their common ancestors. + onOutput?.let { onOutput -> + applied.output?.value?.let(onOutput) + } + } + } + + private fun snapshot(): Snapshot? = interceptedWorkflow.snapshotState(state.value) + + // No need for the overhead of groups, this is only used in a very specific way when we're already + // thinking about groups. + @ExplicitGroupsComposable + @Composable + private inline fun withTrapdoor(block: () -> R): R { + Trapdoor.open { trapdoor -> + this.trapdoor = trapdoor + val result = block() + this.trapdoor = null + return result + } + } + + companion object { + /** + * Hard-coded group key used for all groups that are created by [ComposeRenderContext]s. + * All _types_ of children (child workflows, side effects, etc) must use the same group key so + * that they can be moved among each other correctly. + * + * In most Compose code, this key would be generated by the compiler as a hash of the source + * location of the function. + */ + private const val GROUP_KEY = -9287345 + + @Composable + fun rememberComposeRenderContext( + workflow: Workflow, + props: P, + onOutput: ((O) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, + ): ComposeRenderContext { + val workflowScope = rememberCoroutineScope() + // The only reason we put the scope in a state is because if we just capture it directly + // inside the rememberSaveable lambda, for some reason compose starts freaking out, groups get + // removed for no reason and the RecomposeScope becomes invalid. + // TODO find a less gross way to do this. + val recomposeScope by rememberUpdatedState(currentRecomposeScope) + val renderContext: ComposeRenderContext = rememberSaveable( + saver = Saver( + initialProps = props, + workflow = workflow, + workflowScope = workflowScope, + recomposeScope = recomposeScope, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) + ) { + ComposeRenderContext( + initialProps = props, + workflow = workflow, + recomposeScope = recomposeScope, + workflowScope = workflowScope, + config = config, + parent = parentSession, + renderKey = renderKey, + snapshot = null, + ) + } + renderContext.updatePropsAndOutput(props, onOutput) + + // Values remembered by rememberSaveable don't get RememberObserver callbacks so we need to + // use an effect for it. + DisposableEffect(Unit) { + onDispose { + renderContext.onDisposed() + } + } + + return renderContext + } + } + + private class Saver( + private val initialProps: P, + private val workflow: Workflow, + private val workflowScope: CoroutineScope, + private val recomposeScope: RecomposeScope, + private val config: WorkflowComposableRuntimeConfig, + private val parentSession: WorkflowSession?, + private val renderKey: String, + ) : androidx.compose.runtime.saveable.Saver, Snapshot> { + override fun restore(value: Snapshot): ComposeRenderContext = + ComposeRenderContext( + workflow = workflow, + initialProps = initialProps, + workflowScope = workflowScope, + snapshot = value, + recomposeScope = recomposeScope, + config = config, + parent = parentSession, + renderKey = renderKey, + ) + + override fun SaverScope.save(value: ComposeRenderContext): Snapshot? = + value.snapshot() + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt new file mode 100644 index 0000000000..66c35adc44 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt @@ -0,0 +1,55 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.identifier + +/** Typealias for the signature of [renderChild]. */ +internal typealias ComposableRenderChildFunction = @Composable ( + childWorkflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)?, +) -> RenderingT + +/** + * Intercepts calls to [com.squareup.workflow1.compose.renderChild]. + * See [ComposeWorkflowInterceptor.renderWorkflow] for more info. + * Install an interceptor by providing a [WorkflowComposableRuntimeConfig] via + * [LocalWorkflowComposableRuntimeConfig]. + * + * Note that this interface is [Stable] and so implementations must comply with the contract of that + * annotation. + */ +@Stable +internal interface ComposeWorkflowInterceptor { + + /** + * Called every time [com.squareup.workflow1.compose.renderChild] renders a workflow. + * + * Every "instance" of this method in composition is guaranteed to only ever be called with + * compatible instances of [childWorkflow]. That is, the current and previous values will have the + * same [identifier]. This means implementations do not need to worry about + * [keying off][androidx.compose.runtime.key] the workflow identifier themselves. + * + * Implementations of this method must follow some rules: + * - They MUST NOT call [proceed] more than once. + * - They MAY choose to not call [proceed] at all. + * - They MUST always call [proceed] from the same "position" in composition. Since Compose + * memoizes state positionally, moving the call around will reset the state for the entire + * workflow subtree. If you must move the call around, first try to refactor your code to avoid + * that. If you _really must_ move the call around, use [movableContentOf] but beware this has + * extra cost. + */ + @Composable + fun renderWorkflow( + childWorkflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + proceed: ComposableRenderChildFunction + ): RenderingT +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt new file mode 100644 index 0000000000..b1c9e6e50c --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt @@ -0,0 +1,26 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.currentComposer + +/** + * Like [CompositionLocalProvider] but allows returning a value. + * + * Cash App's Molecule, [Amazon's app-platform](https://github.com/amzn/app-platform/blob/main/presenter-molecule/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/ReturningCompositionLocalProvider.kt), + * and [Circuit](https://github.com/slackhq/circuit/blob/main/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt) + * all have the same workaround, see https://issuetracker.google.com/issues/271871288. + */ +@OptIn(InternalComposeApi::class) +@Composable +internal inline fun withCompositionLocals( + vararg values: ProvidedValue<*>, + crossinline content: @Composable () -> T, +): T { + currentComposer.startProviders(values) + val result = content() + currentComposer.endProviders() + return result +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt new file mode 100644 index 0000000000..b9c0a0c1b9 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt @@ -0,0 +1,45 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.snapshots.StateObject +import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.withCurrent +import androidx.compose.runtime.snapshots.writable + +internal class PeekableMutableState(initialValue: T) : MutableState, StateObject { + private var record = Record(initialValue) + + override var value: T + get() = record.withCurrent { it.value } + set(newValue) { + setWithInvalidator(newValue, invalidator = null) + } + + override fun component1(): T = value + override fun component2(): (T) -> Unit = { value = it } + + fun setWithInvalidator( + newValue: T, + invalidator: (() -> Unit)? + ) { + if (newValue != value) { + record.writable(this) { value = newValue } + invalidator?.invoke() + } + } + + override val firstStateRecord: StateRecord + get() = record + + override fun prependStateRecord(value: StateRecord) { + @Suppress("UNCHECKED_CAST") + record = value as Record + } + + private class Record(var value: T) : StateRecord() { + override fun create(): StateRecord = Record(value) + override fun assign(value: StateRecord) { + this.value = (value as Record).value + } + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt new file mode 100644 index 0000000000..3f41289c80 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt @@ -0,0 +1,433 @@ +@file:OptIn(InternalComposeApi::class) + +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer +import androidx.compose.runtime.ExplicitGroupsComposable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.remember + +// From https://github.com/squareup/market/pull/11218/ + +/** + * Invokes [producer] as a restartable, skippable composable by caching its return value. + * + * The first time this composable is called it runs [producer] and remembers its return value in the + * same way as [remember]. + * + * When the caller is recomposed (calling this composable again), if the same [producer] instance is + * passed and [producer] hasn't been invalidated itself (i.e. due to a state change), then [producer] + * is skipped (i.e. state is kept in the composition but it is not recomposed) and the cached value is + * returned. If a different [producer] instance is passed, it's composed and its return value is cached + * before being returned. + * + * [producer] is _not_ treated as an implicit key: only the + * initial [producer] instance passed to this function will ever be called. The compose compiler will + * normally auto-remember lambdas passed to composables, but _only if they return `Unit`_. Since + * [producer] has a non-`Unit` return type, it will never be auto-remembered. So this function always + * remembers [producer] itself. If we treated [producer] as a key, it would always be recomposed unless + * the caller explicitly remembered it. To update the producer logic, store it in a [MutableState]: + * + * **Maintainer note: If we do this as a compiler plugin, we should also implement auto-memoizing + * lambdas for this case.** + * + * @see rememberSkippableAndRestartableComposable + */ +// We don't need the compiler to generate groups because the impl non-Composable function explicitly +// creates its own groups anyway. +@ExplicitGroupsComposable +@Composable +internal fun rememberSkippableComposable( + key1: Any?, + key2: Any?, + producer: @Composable () -> R +): R = rememberSkippableComposableImpl( + arg1 = key1, + arg2 = key2, + arg3 = Unit, + producer = producer, + composer = currentComposer, + changed = 0, +) + +// // We don't need the compiler to generate groups because the impl non-Composable function explicitly +// // creates its own groups anyway. +// @ExplicitGroupsComposable +// @Composable +// inline fun rememberSkippableComposable( +// key1: Any?, +// key2: Any?, +// producer: @Composable () -> R +// ): R { +// val composer = currentComposer +// val runProducer = rSKC_before( +// arg1 = key1, +// arg2 = key2, +// arg3 = Unit, +// composer = composer, +// ) +// +// val newValue = if (runProducer) { +// producer() +// } else { +// Composer.Empty +// } +// +// return rSKC_after(newValue, composer) +// } + +// // We don't need the compiler to generate groups because the impl non-Composable function explicitly +// // creates its own groups anyway. +// @ExplicitGroupsComposable +// @Composable +// inline fun rememberSkippableComposable( +// key1: Any?, +// key2: Any?, +// key3: Any?, +// producer: @Composable () -> R +// ): R { +// val composer = currentComposer +// val changed = 0 +// +// // Outer group has two "children": The restartable group that might be skipped if no keys have +// // changed, and the remembered return value. +// // Key chosen "randomly" by mashing on my keyboard. +// composer.startReplaceGroup(23975235) +// +// // region Recompose producer +// // Inner group is necessary to be able to skip calling producer. We need a nested group because we +// // only want to skip calling producer, we still need to do other slot table stuff later to +// // read the cache even if producer is skipped. +// // Key chosen "randomly" by mashing on my keyboard. +// composer.startReplaceGroup(-895983) +// +// // We don't consider producer in this change detection code since we don't want to treat it as a +// // key. +// var dirty = changed +// if ((changed and 0b110) == 0) { +// dirty = changed or (if (composer.changed(key1)) 0b100 else 0b010) +// } +// if ((changed and 0b110_000) == 0) { +// dirty = dirty or (if (composer.changed(key2)) 0b100_000 else 0b010_000) +// } +// if ((changed and 0b110_000_000) == 0) { +// dirty = dirty or (if (composer.changed(key3)) 0b100_000_000 else 0b010_000_000) +// } +// +// val newValue = if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { +// composer.skipToGroupEnd() +// Composer.Empty +// } else { +// producer() +// } +// +// composer.endReplaceGroup() +// // endregion +// +// // region Update cache +// // Cache the return value in case we skipped above. Composer APIs require always reading the value +// // first, and then calling updateRememberedValue the first time or optionally on subsequent +// // recompositions. +// val oldValue = composer.rememberedValue() +// +// @Suppress("SuspiciousEqualsCombination") +// val returnValue = if ( +// oldValue === Composer.Empty || +// (newValue !== Composer.Empty && newValue != oldValue) +// ) { +// // Update the cache. +// if (newValue === Composer.Empty) error("No new value was calculated.") +// composer.updateRememberedValue(newValue) +// newValue +// } else { +// // Producer was skipped (or returned the same value), return from the cache. +// oldValue +// } +// // endregion +// +// composer.endReplaceGroup() +// @Suppress("UNCHECKED_CAST") +// return returnValue as R +// } + +/** + * Invokes [producer] as a restartable, skippable composable by caching its return value. + * + * The first time this composable is called it runs [producer] and remembers its return value in the + * same way as [remember]. + * + * When the caller is recomposed (calling this composable again), if the same [producer] instance is + * passed and [producer] hasn't been invalidated itself (i.e. due to a state change), then [producer] + * is skipped (i.e. state is kept in the composition but it is not recomposed) and the cached value is + * returned. If a different [producer] instance is passed, it's composed and its return value is cached + * before being returned. + * + * When [producer] is invalidated independently (i.e. due to a state it read being changed), without + * the caller being invalidated, then just [producer] is recomposed at first and its return value is + * compared with the cached value: If it's different, and only if it's different, then the new value + * is cached and the caller is invalidated, which will cause the caller to be recomposed during the + * same composition phase of the same frame. When this happens, the same caching behavior applies: if + * the same instance of [producer] is passed in then it will be skipped and the cached value will be + * returned. + * + * [producer] is _not_ treated as an implicit key: only the + * initial [producer] instance passed to this function will ever be called. The compose compiler will + * normally auto-remember lambdas passed to composables, but _only if they return `Unit`_. Since + * [producer] has a non-`Unit` return type, it will never be auto-remembered. So this function always + * remembers [producer] itself. If we treated [producer] as a key, it would always be recomposed unless + * the caller explicitly remembered it. To update the producer logic, store it in a [MutableState]: + * + * **Maintainer note: If we do this as a compiler plugin, we should also implement auto-memoizing + * lambdas for this case.** + * + * @see rememberSkippableComposable + */ +// We don't need the compiler to generate groups because the impl non-Composable function has to +// explicitly create its own groups anyway. +@ExplicitGroupsComposable +@Composable +internal fun rememberSkippableAndRestartableComposable( + key1: Any?, + key2: Any?, + producer: @Composable () -> R +): R = rememberSkippableAndRestartableComposableImpl( + arg1 = key1, + arg2 = key2, + arg3 = Unit, + producer = producer, + callerRecomposeScope = currentRecomposeScope, + composer = currentComposer, + changed = 0, + invalidateCallerOnNewValue = false +) + +@PublishedApi +internal fun rSKC_before( + arg1: Any?, + arg2: Any?, + arg3: Any?, + composer: Composer, + changed: Int = 0, +): Boolean { + // Outer group is restartable: This should wrap the entire body of this function (except the actual + // return statement) and is what defines the recompose scope for producer. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(23975235) + + // Inner group is necessary to be able to skip calling producer. We need a nested group because we + // only want to skip calling producer, we still need to do other slot table stuff later to + // read the cache even if producer is skipped. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(-895983) + + // We don't consider producer in this change detection code since we don't want to treat it as a key. + var dirty = changed + if ((changed and 0b110) == 0) { + dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) + } + if ((changed and 0b110_000) == 0) { + dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) + } + if ((changed and 0b110_000_000) == 0) { + dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) + } + if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { + composer.skipToGroupEnd() + return false + } else { + return true + } +} + +@PublishedApi +internal fun rSKC_after( + newValue: Any?, + composer: Composer, +): R { + composer.endReplaceGroup() + + // Cache the return value in case we skipped above. Composer APIs require always reading the value + // first, and then calling updateRememberedValue the first time or optionally on subsequent + // recompositions. + val oldValue = composer.rememberedValue() + + @Suppress("SuspiciousEqualsCombination") + val returnValue = if ( + oldValue === Composer.Empty || + (newValue !== Composer.Empty && newValue != oldValue) + ) { + // Update the cache. + if (newValue === Composer.Empty) error("No new value was calculated.") + composer.updateRememberedValue(newValue) + newValue + } else { + // Producer was skipped (or returned the same value), return from the cache. + oldValue + } + + composer.endReplaceGroup() + @Suppress("UNCHECKED_CAST") + return returnValue as R +} + +@Suppress("SuspiciousEqualsCombination", "UNCHECKED_CAST") +@PublishedApi +internal fun rememberSkippableComposableImpl( + arg1: Any?, + arg2: Any?, + arg3: Any?, + producer: @Composable () -> R, + composer: Composer, + changed: Int, +): R { + // Outer group is restartable: This should wrap the entire body of this function (except the actual + // return statement) and is what defines the recompose scope for producer. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(23975235) + + // region Recompose producer + // Inner group is necessary to be able to skip calling producer. We need a nested group because we + // only want to skip calling producer, we still need to do other slot table stuff later to + // read the cache even if producer is skipped. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(-895983) + + // We don't consider producer in this change detection code since we don't want to treat it as a key. + var dirty = changed + if ((changed and 0b110) == 0) { + dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) + } + if ((changed and 0b110_000) == 0) { + dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) + } + if ((changed and 0b110_000_000) == 0) { + dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) + } + + val newValue = if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { + composer.skipToGroupEnd() + Composer.Empty + } else { + (producer as (Composer, Int) -> R).invoke(composer, 0) + } + + composer.endReplaceGroup() + // endregion + + // region Update cache + // Cache the return value in case we skipped above. Composer APIs require always reading the value + // first, and then calling updateRememberedValue the first time or optionally on subsequent + // recompositions. + val oldValue = composer.rememberedValue() + val returnValue = if ( + oldValue === Composer.Empty || + (newValue !== Composer.Empty && newValue != oldValue) + ) { + // Update the cache. + if (newValue === Composer.Empty) error("No new value was calculated.") + composer.updateRememberedValue(newValue) + newValue + } else { + // Producer was skipped (or returned the same value), return from the cache. + oldValue + } + // endregion + + composer.endReplaceGroup() + return returnValue as R +} + +@Suppress("SuspiciousEqualsCombination", "UNCHECKED_CAST", "NAME_SHADOWING") +@PublishedApi +internal fun rememberSkippableAndRestartableComposableImpl( + arg1: Any?, + arg2: Any?, + arg3: Any?, + producer: @Composable () -> R, + callerRecomposeScope: RecomposeScope, + composer: Composer, + changed: Int, + invalidateCallerOnNewValue: Boolean +): R { + // Outer group is restartable: This should wrap the entire body of this function (except the actual + // return statement) and is what defines the recompose scope for producer. + // Key chosen "randomly" by mashing on my keyboard. + composer.startRestartGroup(23975234) + + // Only gets set if we end up composing producer this invocation. + var newValue: Any? = Composer.Empty + + // region Recompose producer + // Inner group is necessary to be able to skip calling producer. We need a nested group because we + // only want to skip calling producer, we still need to do other slot table stuff later to + // read the cache even if producer is skipped. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(-895982) + + // We don't consider producer in this change detection code since we don't want to treat it as a key. + var dirty = changed + if ((changed and 0b110) == 0) { + dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) + } + if ((changed and 0b110_000) == 0) { + dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) + } + if ((changed and 0b110_000_000) == 0) { + dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) + } + if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { + composer.skipToGroupEnd() + } else { + newValue = (producer as (Composer, Int) -> R).invoke(composer, 0) + } + + composer.endReplaceGroup() + // endregion + + // region Update cache + // Cache the return value in case we skipped above. Composer APIs require always reading the value + // first, and then calling updateRememberedValue the first time or optionally on subsequent + // recompositions. + val oldValue = composer.rememberedValue() + val returnValue = if ( + oldValue === Composer.Empty || + (newValue !== Composer.Empty && newValue != oldValue) + ) { + // Update the cache. + if (newValue === Composer.Empty) error("No new value was calculated.") + composer.updateRememberedValue(newValue) + + // When we're recomposed directly, we obviously can't return returnValue to the original caller, so + // just invalidate it instead. It will eventually recompose after we're done in the same frame, and + // when it does so it should hit the cache (unless the caller passes a new producer). + if (invalidateCallerOnNewValue) { + callerRecomposeScope.invalidate() + } + newValue + } else { + // Producer was skipped (or returned the same value), return from the cache. + oldValue + } + // endregion + + composer.endRestartGroup()?.updateScope { composer, changed -> + // This lambda is called when producer is invalidated. The lambda must create a restartable group + // with the same key to preserve positional identity. + rememberSkippableAndRestartableComposableImpl( + arg1 = arg1, + arg2 = arg2, + arg3 = arg3, + producer = producer, + callerRecomposeScope = callerRecomposeScope, + composer = composer, + changed = changed, + invalidateCallerOnNewValue = true, + ) + } + return returnValue as R +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt new file mode 100644 index 0000000000..d09671e549 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -0,0 +1,68 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.internal.WorkflowNode +import com.squareup.workflow1.internal.compose.ComposeRenderContext.Companion.rememberComposeRenderContext +import com.squareup.workflow1.renderWorkflowIn + +/** + * This is the entry point for hosting a workflow tree inside a composition. It manages all the + * bookkeeping for the workflow session. It's analogous to [WorkflowNode] in the traditional + * runtime. + * + * It is called from at least two places: + * - The root of the compose workflow runtime, from [renderWorkflowIn]. + * - Any time a workflow renders a child (see [ComposeRenderContext]). + * + * In the future, it could potentially become public API for rendering child workflows from + * workflows that are written as actual composable functions, but exposing it publicly would require + * some additional work to ensure it can't be called incorrectly (ensuring [config] doesn't change, + * hiding [parentSession], keying on `workflow.identifier`, etc.) + * + * @param config Workflow-tree-wide configuration that must never change during the lifetime of the + * runtime. This is not currently enforced because doing so would incur some overhead in the slot + * table, but behavior is undefined if it does change. + * @param renderKey The key passed to the [com.squareup.workflow1.BaseRenderContext.renderChild] + * function by the parent workflow. This is only used to construct the child's [WorkflowSession], + * and is not used for actual keying. [ComposeRenderContext] does the actual keying. + */ +@Composable +internal fun renderWorkflow( + workflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, +): RenderingT { + // The lifetime of the workflow session is tied to the workflow.identifier, but we don't key on it + // here since it's already keyed from ComposeRenderContext. + + // TODO Skipping without restarting doesn't work, since we have no way to know whether the state + // changed and we need to recompose anyway. I think the only way to get this information is + // from the changed parameter that the compiler generates for this composable, but we don't + // have any way to access that. + // TODO Skipping *with* restarting seems to work, but it requires allocating a composable lambda + // for the content and the trampolining is weird, so I'm leaving it disabled while I iron out + // the rest of the kinks. + // // Skip re-rendering when possible but force recompose when new props or output handler are + // passed. + // rememberSkippableComposable(props, onOutput) { + // rememberSkippableAndRestartableComposable(props, onOutput) { + + val baseContext = rememberComposeRenderContext( + workflow = workflow, + props = props, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) + + // TODO this feels weird to have outside the context, should it be moved in too? Should this whole + // function be moved into the context? I think unit tests will be the only real forcing function, + // so let's write some tests and see how it works. + return baseContext.renderSelf(props) +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt new file mode 100644 index 0000000000..2cbd8fb285 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt @@ -0,0 +1,231 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import com.squareup.workflow1.RenderingAndSnapshot +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.RenderingProduced +import com.squareup.workflow1.WorkflowInterceptor.RuntimeSettled +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.internal.IdCounter +import com.squareup.workflow1.internal.compose.TraceLabels.PerformSave +import com.squareup.workflow1.internal.compose.TraceLabels.Recompose +import com.squareup.workflow1.internal.compose.runtime.SynchronizedMolecule +import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule +import com.squareup.workflow1.internal.requireSend +import com.squareup.workflow1.parse +import com.squareup.workflow1.readByteStringWithLength +import com.squareup.workflow1.readList +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.trace +import com.squareup.workflow1.writeByteStringWithLength +import com.squareup.workflow1.writeList +import com.squareup.workflow1.writeUtf8WithLength +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import okio.ByteString + +@OptIn(ExperimentalComposeRuntimeApi::class) +internal fun renderWorkflowWithComposeRuntimeIn( + workflow: Workflow, + scope: CoroutineScope, + props: StateFlow, + initialSnapshot: TreeSnapshot? = null, + interceptor: WorkflowInterceptor, + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + workflowTracer: WorkflowTracer? = null, + onOutput: suspend (OutputT) -> Unit +): StateFlow> { + val outputs = Channel(capacity = 1000) + val recomposeRequests = Channel(capacity = 1) + val composableConfig = WorkflowComposableRuntimeConfig( + workflowInterceptor = interceptor, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + idCounter = IdCounter() + ) + val saveableStateRegistry = createSaveableStateRegistryForTreeSnapshot(initialSnapshot) + + // Explicitly store this lambda in a val so it doesn't get re-allocated every time, causing + // recomposeWithContent to recompose unnecessarily. + val content: @Composable () -> RenderingT = { + val currentProps by props.collectAsState() + withCompositionLocals(LocalSaveableStateRegistry provides saveableStateRegistry) { + renderWorkflow( + workflow = workflow, + props = currentProps, + onOutput = outputs::requireSend, + config = composableConfig, + parentSession = null, + renderKey = "" + ) + } + } + + fun SynchronizedMolecule.recompose(): RenderingAndSnapshot { + var rendering: RenderingT + workflowTracer.trace(Recompose) { + rendering = recomposeWithContent(content) + // I think this can only happen on the initial compose – otherwise we've got a backwards write + // or something. + while (recomposeRequests.tryReceive().isSuccess) { + rendering = recomposeWithContent(content) + } + } + + // Must perform the save eagerly so that the *current* state values are captured, instead of the + // future values when the snapshot is actually serialized. This is less efficient, but matches + // the behavior of the traditional workflow runtime. + val savedValues = workflowTracer.trace(PerformSave) { + saveableStateRegistry.performSave() + } + val snapshot = savedValuesToSnapshot(savedValues) + val treeSnapshot = TreeSnapshot(snapshot, childTreeSnapshots = ::emptyMap) + + return RenderingAndSnapshot(rendering, snapshot = treeSnapshot) + } + + val molecule = scope.launchSynchronizedMolecule( + onNeedsRecomposition = { recomposeRequests.trySend(Unit) } + ) + val initialRenderingAndSnapshot = molecule.recompose() + val renderingsAndSnapshots = MutableStateFlow(initialRenderingAndSnapshot) + + interceptor.onRuntimeUpdate(RenderingProduced) + interceptor.onRuntimeUpdate(RuntimeSettled) + + scope.launch { + while (true) { + // TODO it saves time to not use channels, but that was done without handling outputs so not + // sure if the gains would persist if we added that functionality back in. + // awaitRecomposeRequest() + // renderingsAndSnapshots.value = molecule.recompose() + select { + // Must receive from outputs first so the outputs channel will be fully drained before + // allowing recomposition to continue. + outputs.onReceive { output -> + val maybeRecomposeRequest = recomposeRequests.tryReceive() + if (maybeRecomposeRequest.isSuccess) { + // We need to publish the new rendering before sending any outputs, but we need to drain + // the outputs queue before recomposing to maintain ordering. + val outputsToSend = mutableListOf(output) + var maybeOutput = outputs.tryReceive() + while (maybeOutput.isSuccess) { + outputsToSend += maybeOutput.getOrThrow() + maybeOutput = outputs.tryReceive() + } + + // First send the new rendering, to comply with workflow expectations. + renderingsAndSnapshots.value = molecule.recompose() + interceptor.onRuntimeUpdate(RenderingProduced) + interceptor.onRuntimeUpdate(RuntimeSettled) + + // Then send all the outputs that happened before recomposition. + outputsToSend.forEach { + onOutput(it) + } + } else { + onOutput(output) + } + } + recomposeRequests.onReceive { + renderingsAndSnapshots.value = molecule.recompose() + interceptor.onRuntimeUpdate(RenderingProduced) + interceptor.onRuntimeUpdate(RuntimeSettled) + } + } + } + } + + return renderingsAndSnapshots +} + +private fun createSaveableStateRegistryForTreeSnapshot( + treeSnapshot: TreeSnapshot? +): SaveableStateRegistry { + val snapshot = treeSnapshot?.workflowSnapshot + val restoredValues = snapshotToRestoredValues(snapshot) + return SaveableStateRegistry( + restoredValues = restoredValues, + canBeSaved = { it is Snapshot || (it is MutableState<*> && it.value is Snapshot) } + ) +} + +private fun snapshotToRestoredValues(snapshot: Snapshot?): Map>? { + if (snapshot == null) return null + return buildMap { + snapshot.bytes.parse { source -> + val mapSize = source.readInt() + repeat(mapSize) { + val key = source.readUtf8WithLength() + val snapshots: List = source.readList { + when (val valueTypeTag = source.readByte()) { + 0.toByte() -> { + // Direct snapshot. + val bytes = source.readByteStringWithLength() + if (bytes.size == 0) null else Snapshot.of(bytes) + } + + 1.toByte() -> { + // MutableState of snapshot. + val bytes = source.readByteStringWithLength() + val snapshot = if (bytes.size == 0) null else Snapshot.of(bytes) + snapshot?.let(::mutableStateOf) + } + + else -> error("Unknown tag: $valueTypeTag") + } + } + if (snapshots.isNotEmpty()) { + put(key, snapshots) + } + } + } + } +} + +private fun savedValuesToSnapshot(savedValues: Map>): Snapshot = + Snapshot.write { sink -> + sink.writeInt(savedValues.size) + savedValues.entries.forEach { (key, snapshots) -> + sink.writeUtf8WithLength(key) + sink.writeList(snapshots) { + when (it) { + is Snapshot? -> { + sink.writeByte(0) + val snapshot = it + val bytes = snapshot?.bytes ?: ByteString.EMPTY + sink.writeByteStringWithLength(bytes) + } + + is MutableState<*> -> { + sink.writeByte(1) + val snapshot = it.value as Snapshot? + val bytes = snapshot?.bytes ?: ByteString.EMPTY + sink.writeByteStringWithLength(bytes) + } + + else -> error( + "Expected saved state value to be a Snapshot or MutableState, " + + "but was $it" + ) + } + + } + } + } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraceLabels.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraceLabels.kt new file mode 100644 index 0000000000..d249b924fe --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraceLabels.kt @@ -0,0 +1,10 @@ +package com.squareup.workflow1.internal.compose + +@Suppress("ConstPropertyName") +internal object TraceLabels { + const val Recompose = "Recompose" + const val InitialState = "ComposeInitialState" + const val OnPropsChanged = "ComposeOnPropsChanged" + const val SendAction = "ComposeSendAction" + const val PerformSave = "PerformSave" +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/Trapdoor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/Trapdoor.kt new file mode 100644 index 0000000000..7bbcb229ab --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/Trapdoor.kt @@ -0,0 +1,139 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer +import androidx.compose.runtime.ExplicitGroupsComposable +import androidx.compose.runtime.currentComposer +import com.squareup.workflow1.internal.compose.Trapdoor.Companion.open +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind.EXACTLY_ONCE +import kotlin.contracts.contract +import kotlin.jvm.JvmInline + +/** + * Helper to locally break out of a composable and then re-enter composition from non-composable + * code. Call [open] to run some non-composable code inline that can re-enter composition by calling + * [inMovableGroup]. + * + * **Trapdoors open into pits of danger! This is a highly dangerous API that can easily put a + * composition into an invalid state. Use with extreme care!** + * + * ## Usage + * + * This code… + * ``` + * @Composable fun OuterComposable() { + * Trapdoor.open { trapdoor -> + * nonComposable(trapdoor) + * } + * } + * + * private fun nonComposable(trapdoor: Trapdoor) { + * trapdoor.composeReturning { + * InnerComposable() + * } + * } + * + * @Composable private fun InnerComposable() { + * BasicText("Inside!") + * } + * ``` + * …is, as far as Compose is concerned, equivalent to this: + * ``` + * @Composable fun OuterComposable() { + * InnerComposable() + * } + * ``` + * Both generate the exact same grouping calls and slot table. + */ +@Suppress("UNCHECKED_CAST") +@JvmInline +internal value class Trapdoor(private val composer: Composer) { + + /** + * Calls [content] as if it were called directly in composition from wherever this [Trapdoor] was + * [open]ed. This function places a "movable group" around [content] with the given [key] and + * [dataKey]. This means that if there are multiple calls to this function from inside the same + * parent group, and they all have the same [key], then the order of the calls can change and + * calls can be added and removed in subsequent recompositions, and the data inside each child + * group will be associated with the [dataKey]. + */ + @OptIn(ExperimentalContracts::class) + fun inMovableGroup( + key: Int, + dataKey: Any?, + content: @Composable () -> R + ): R { + @Suppress("WRONG_INVOCATION_KIND") + contract { callsInPlace(content, kind = EXACTLY_ONCE) } + val invokableContent = content as (Composer, Int) -> R + // TODO: Just discovered it's necessary to put a movable group here, probably because otherwise + // `content` always has its own internal group and so putting a `key` call in there is too + // late. Need to think more about implications, how to clean this up, does the hash key need + // to be the same for everything in a renderContext (I think so), etc. + composer.startMovableGroup(key, dataKey) + return invokableContent.invoke(composer, 0) + .also { composer.endMovableGroup() } + } + + /** + * Calls [content] as if it were called directly in composition from wherever this [Trapdoor] was + * [open]ed. This function places a "movable group" around [content] with the given [key] and + * [dataKey]. This means that if there are multiple calls to this function from inside the same + * parent group, and they all have the same [key], then the order of the calls can change and + * calls can be added and removed in subsequent recompositions, and the data inside each child + * group will be associated with the [dataKey]. + */ + // TODO It really is not ideal that this has to allocate a new lambda on every render pass. We + // can avoid this by making inMovableGroup take a few non-key params that are just forwarded to + // the content param, and then we can just manually cache the lambda in a field in + // ComposeRenderContext. + @OptIn(ExperimentalContracts::class) + fun inMovableGroup( + key: Int, + dataKey1: Any?, + dataKey2: Any?, + content: @Composable () -> R + ): R { + contract { callsInPlace(content, kind = EXACTLY_ONCE) } + return inMovableGroup(key, composer.joinKey(dataKey1, dataKey2), content) + } + + companion object { + /** + * Runs [block] inline with a [Trapdoor] object that can be used to re-enter composition by + * calling [inMovableGroup]. + */ + // No reason for the overhead of additional groups here, if Trapdoor is being used you're + // already in Hard Mode. + @ExplicitGroupsComposable + @Composable + inline fun open(block: (Trapdoor) -> R): R = block(Trapdoor(currentComposer)) + + @Composable + fun open(): Trapdoor = Trapdoor(currentComposer) + + /** + * Uses Compose's slot table to detect changes to [value] between recompositions and calls + * [ifChanged] when a change is detected. + */ + @Composable + inline fun runIfValueChanged( + value: T, + ifChanged: (oldValue: T) -> Unit + ) { + val composer = currentComposer + val oldValue = composer.rememberedValue() + val wasEmpty = oldValue === Composer.Empty + val didChange = oldValue != value + + if (wasEmpty || didChange) { + composer.updateRememberedValue(value) + } + + if (!wasEmpty && didChange) { + ifChanged(oldValue as T) + } + } + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowComposableRuntimeConfig.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowComposableRuntimeConfig.kt new file mode 100644 index 0000000000..b5f82eacde --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowComposableRuntimeConfig.kt @@ -0,0 +1,46 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Immutable +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.internal.IdCounter + +/** + * Defines configuration used by [renderWorkflow] when rendering workflows. + * + * This is an immutable class with value semantics. + */ +// Impl note: This is not a data class since it's public API, but its implementation of equals is +// critical since providing a different instance resets the entire workflow tree. +@Immutable +internal class WorkflowComposableRuntimeConfig( + val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + val workflowTracer: WorkflowTracer? = null, + val workflowInterceptor: WorkflowInterceptor? = null, + val idCounter: IdCounter? = null, +) { + + override fun toString(): String = "WorkflowComposableRuntimeConfig(" + + "runtimeConfig=$runtimeConfig, " + + "workflowTracer=$workflowTracer)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as WorkflowComposableRuntimeConfig + + if (runtimeConfig != other.runtimeConfig) return false + if (workflowTracer != other.workflowTracer) return false + + return true + } + + override fun hashCode(): Int { + var result = runtimeConfig.hashCode() + result = 31 * result + (workflowTracer?.hashCode() ?: 0) + return result + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaver.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaver.kt new file mode 100644 index 0000000000..e36f55be18 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaver.kt @@ -0,0 +1,36 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.trace +import kotlinx.coroutines.CoroutineScope + +/** + * Uses [statefulWorkflow]'s [StatefulWorkflow.snapshotState] and [StatefulWorkflow.initialState] + * methods to save and restore its state to a [ByteArray] that can be stored in a bundle or + * serialized inside [renderChild]. + */ +internal class WorkflowSnapshotSaver( + private val initialProps: PropsT, + private val statefulWorkflow: StatefulWorkflow, + private val workflowTracer: WorkflowTracer?, + private val workflowScope: CoroutineScope, +) : Saver { + + @OptIn(WorkflowExperimentalApi::class) + override fun restore(value: Snapshot): StateT? = + workflowTracer.trace(TraceLabels.InitialState) { + statefulWorkflow.initialState( + props = initialProps, + snapshot = value, + workflowScope = workflowScope, + ) + } + + override fun SaverScope.save(value: StateT): Snapshot? = + statefulWorkflow.snapshotState(value) +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt new file mode 100644 index 0000000000..c74dc43b25 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt @@ -0,0 +1,66 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.internal.Lock +import com.squareup.workflow1.internal.withLock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile + +/** + * When running benchmarks, using the Main dispatcher for sending apply notifications creates a race + * condition where the main thread is in the middle of sending apply notifications so the benchmark + * thread thinks they're sent and continues, which means sometimes recomposition isn't scheduled + * until after the test scheduler is finished advancing, which kind of means the frame is missed. + */ +public fun setGlobalSnapshotManagerSendApplyImmediately(value: Boolean) { + GlobalSnapshotManager.setSendApplyImmediately(value) +} + + +internal object GlobalSnapshotManager { + private var started = false + private var applyScheduled = false + @Volatile private var sendApplyImmediately = false + private val lock = Lock() + + fun setSendApplyImmediately(value: Boolean) { + sendApplyImmediately = value + } + + fun ensureStarted() { + lock.withLock { + if (started) return + started = true + } + + Snapshot.registerGlobalWriteObserver { + val launchApply = lock.withLock { + if (!applyScheduled) { + applyScheduled = true + true + } else { + false + } + } + if (launchApply) { + if (sendApplyImmediately) { + lock.withLock { + applyScheduled = false + } + Snapshot.sendApplyNotifications() + } else { + CoroutineScope(GlobalSnapshotCoroutineDispatcher).launch { + lock.withLock { + applyScheduled = false + } + Snapshot.sendApplyNotifications() + } + } + } + } + } +} + +internal expect val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt new file mode 100644 index 0000000000..7c8c57c3a1 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt @@ -0,0 +1,332 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.NullableInitBox +import com.squareup.workflow1.internal.Lock +import com.squareup.workflow1.internal.WorkStealingDispatcher +import com.squareup.workflow1.internal.withLock +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.concurrent.Volatile + +/** + * Creates a [launchSynchronizedMolecule] that will run its recomposition loop and effects in + * this [CoroutineScope]. [onNeedsRecomposition] must ensure that + * [SynchronizedMolecule.recomposeWithContent] is eventually called. + * + * See [SynchronizedMolecule] for more information. + */ +internal fun CoroutineScope.launchSynchronizedMolecule( + onNeedsRecomposition: () -> Unit +): SynchronizedMolecule = RealSynchronizedMolecule( + scope = this, + onNeedsRecomposition = onNeedsRecomposition, +) + +/** + * A self-contained Compose runtime (like Molecule) that is driven by explicitly telling it when to + * recompose. + * + * ## Usage + * + * Create an instance of this interface by calling [launchSynchronizedMolecule] and passing the + * [CoroutineScope] used to run recomposition and effects, as well as a function to schedule + * a call to [recomposeWithContent] when composition state is changed. When you're ready to + * compose the initial content, call [recomposeWithContent]: this will compose the composable passed + * to it and the compose runtime will start observing state changes. When state is changed, the + * scheduling function passed to [launchSynchronizedMolecule] will be called, but the composition + * will not be recomposed until [recomposeWithContent] is called again. The scheduling function will + * never be called more than once before the next call to [recomposeWithContent]. + * + * To check if composition state has changed imperatively, check [needsRecomposition]. + * + * To stop observing state changes, either cancel the [CoroutineScope] or call + * [SynchronizedMolecule.close]. + * + * ## Implementation and runtime behavior + * + * The compose runtime is driven by an instance of [Recomposer] that runs the recomposition loop + * in a coroutine, scheduling recompositions via a [MonotonicFrameClock]. When compose snapshot + * state is changed, the recomposer eventually gets notified about the changes, which resumes an + * internal suspending loop, and eventually requests a frame from its frame clock. + * + * This class helps out by collapsing some of those steps: As soon as snapshot apply notifications + * have been sent this class will immediately make the frame request available, even if the + * underlying dispatcher wouldn't have normally resumed the recompose loop in time to make the frame + * request. It does this by running the recompose loop on a special dispatcher that wraps the + * underlying dispatcher but allows us to explicitly advance for the recompose loop, and by + * advancing it any time the recomposer reports pending work but hasn't + * requested a frame yet. + */ +internal interface SynchronizedMolecule { + + /** + * Returns true if the last composable passed to [recomposeWithContent] needs to be recomposed + * due to some state that it read being changed. + * + * May start returning true before `onNeedsRecomposition` is called if the underlying dispatcher + * hasn't had a chance to request a frame yet. + */ + val needsRecomposition: Boolean + + /** + * Performs a recomposition with the given [content] and returns its result. + * + * If the composition throws an exception, the exception will be thrown directly from this method. + * The composition task will be cancelled but will not get the exception directly. This method + * will immediately throw if called again after failure. + */ + fun recomposeWithContent(content: @Composable () -> R): R + + /** + * Stop observing composition state (calling `onNeedsRecomposition`). After calling this, it is + * an error to call [recomposeWithContent] again, and [needsRecomposition] will always return + * false. + * + * You don't need to call this if the coroutine scope is cancelled. + */ + fun close() +} + +private class RealSynchronizedMolecule( + scope: CoroutineScope, + private val onNeedsRecomposition: () -> Unit, +) : SynchronizedMolecule, MonotonicFrameClock { + + private val recompositionParentJob = Job(parent = scope.coroutineContext.job) + private val scope = scope + recompositionParentJob + + init { + GlobalSnapshotManager.ensureStarted() + } + + /** + * It's fine to run the recompose loop on Unconfined since it's thread-safe internally, and + * the only threading guarantee we care about is that the frame callback is executed during + * the render pass, which we already control and doesn't depend on what dispatcher is used. + */ + private val recomposeDispatcher = WorkStealingDispatcher(Dispatchers.Unconfined) + private val recomposer: Recomposer = Recomposer( + effectCoroutineContext = scope.coroutineContext + ) + private val composition: Composition = Composition(UnitApplier, recomposer) + private var content: (@Composable () -> Any?)? by mutableStateOf(null) + private var lastResult: NullableInitBox = NullableInitBox() + + /** Used to synchronize access to [frameRequest]. */ + private val lock = Lock() + + @Volatile + private var frameRequest: FrameRequest<*>? = null + + /** + * Used to stop [withFrameNanos] from calling [onNeedsRecomposition] when the frame is being + * requested inside of [recomposeWithContent]. + */ + // TODO this should be a ThreadLocal since withFrameNanos can be called from any thread but + // it should only be true from the thread calling recomposeWithContent. + @Volatile + private var recomposing = false + + override val needsRecomposition: Boolean + get() { + if (frameRequest == null && recomposer.hasPendingWork) { + // Allow the recompose loop to run and maybe request a frame. + recomposeDispatcher.advanceUntilIdle() + } + return frameRequest != null + } + + init { + // setContent will synchronously perform the first recomposition before returning, which is why + // we leave contentAfterInitial null for now: we don't want it to be called until we're actually + // inside tryPerformRecompose. + // We also need to set the composition content before calling startComposition so it doesn't + // need to suspend to wait for it. + // contentAfterInitial isn't snapshot state but that's fine, since when the recomposer is + // started it will always recompose, childNode will be non-null by then, and it will never + // change again. + composition.setContent { + content?.let { content -> + val result = content() + SideEffect { + this.lastResult = NullableInitBox(result) + } + } + } + } + + @OptIn(InternalCoroutinesApi::class) + override fun recomposeWithContent( + content: @Composable () -> R + ): R { + if (recompositionParentJob.isCancelled) { + throw IllegalStateException( + "recomposeWithContent called after composition coroutine scope was cancelled", + recompositionParentJob.getCancellationException() + ) + } + + // Update content in a snapshot to ensure it is applied before we ask for a frame. + // Snapshot.withMutableSnapshot { + // TODO get rid of this entirely, only allow content to be specified in constructor. This is a + // useless state write for our one use case and slow. + this.content = content + // } + Snapshot.sendApplyNotifications() + + if (!lastResult.isInitialized) { + // Initial request kicks off the recompose loop. This should synchronously request a frame. + launchComposition() + } + + // Synchronously recompose any invalidated composables, if any, and update lastResult. + val frameRequest = tryGetFrameRequest() + if (frameRequest == null) { + if (!lastResult.isInitialized) { + error("Expected initial composition to synchronously request initial frame.") + } + } else { + // Hard-code unchanging frame time since there's no actual frame time code shouldn't rely on + // this value. + val frameResult = frameRequest.execute(0L) + + // If the composition threw an exception, re-throw it ourselves now instead of waiting for the + // scope to get it, since lastResult may have not been initialized in this case and we'd throw + // below and get suppressed. + frameResult.exceptionOrNull()?.let { + // This exception has the stack trace inside the composables, but FrameRequest already adds + // a suppressed exception on it that includes the stack trace of this recomposeWithContent + // call. + throw it + } + + // If the composition threw an exception, we want it to cancel the coroutine scope before + // getOrThrow below does so. + recomposeDispatcher.advanceUntilIdle() + } + + @Suppress("UNCHECKED_CAST") + return lastResult.getOrThrow() as R + } + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + // println("compose workflow withFrameNanos (dispatcher=${currentCoroutineContext()[CoroutineDispatcher]})") + // RuntimeException().printStackTrace() + + return suspendCancellableCoroutine { continuation -> + lock.withLock { + if (frameRequest != null) error("Concurrent frame request") + frameRequest = FrameRequest( + onFrame = onFrame, + continuation = continuation + ) + } + + if (!recomposing) { + onNeedsRecomposition() + } + } + } + + override fun close() { + recomposer.close() + } + + private fun launchComposition() { + val frameClock: MonotonicFrameClock = this + + // Launch as undispatched to ensure the composition has a chance to start before this method + // returns, and so that the composition is always disposed even if our job is cancelled + // immediately. + scope.launch( + // Note: This context is _only_ used for the actual recompose loop. Everything inside the + // composition (rememberCoroutineScope, LaunchedEffects, etc) will NOT see these, and will see + // only whatever context was passed into the Recomposer's constructor (plus the stuff it adds + // to that context itself, like the BroadcastFrameClock). + context = recomposeDispatcher + frameClock, + start = UNDISPATCHED + ) { + try { + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + } + + /** + * Returns a [FrameRequest] representing the next frame request if the recomposer has work to do, + * otherwise returns null. This method is best-effort: It tries to poke the recomposer to request + * a frame even if it hasn't had a chance to resume its recompose loop yet. + * + * The returned continuation should be resumed with the frame time in nanoseconds. + */ + private fun tryGetFrameRequest(): FrameRequest<*>? { + // Fast paths: A request was already enqueued, or… + // frameRequestChannel.tryReceive().getOrNull()?.let { return it } + consumeFrameRequest()?.let { + return it + } + + // …no request was enqueued, and the recomposer doesn't need one. + if (!recomposer.hasPendingWork) { + // The recomposer is waiting for work, so there won't be a frame request even if we advance + // the dispatcher. + return null + } + + // Slow path: The recomposer is waiting for its recompose loop to be resumed so it can request + // a frame, so let it do that. + // Set recomposing to avoid calling onRecomposeNeeded if this advancing triggers withFrameNanos. + recomposing = true + recomposeDispatcher.advanceUntilIdle() + recomposing = false + + // If there's still no request then the recomposer either didn't actually need to resume or it + // did but decided not to request a frame, either way we've done all we can. + // return frameRequestChannel.tryReceive().getOrNull() + return consumeFrameRequest() + } + + private fun consumeFrameRequest(): FrameRequest<*>? = lock.withLock { + frameRequest?.also { frameRequest = null } + } + + private inner class FrameRequest( + private val onFrame: (frameTimeNanos: Long) -> R, + private val continuation: CancellableContinuation + ) { + fun execute(frameTimeNanos: Long): Result { + val frameResult = runCatching { onFrame(frameTimeNanos) } + val error = frameResult.exceptionOrNull() + if (error == null) { + continuation.resumeWith(frameResult) + } else { + recompositionParentJob.cancel( + message = "Composition threw an exception, reporting to recompose caller instead.", + cause = error + ) + } + return frameResult + } + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt new file mode 100644 index 0000000000..8299e31b1f --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt @@ -0,0 +1,42 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.Applier + +internal object UnitApplier : Applier { + override val current: Unit + get() = Unit + + override fun clear() { + } + + override fun down(node: Unit) { + } + + override fun insertBottomUp( + index: Int, + instance: Unit + ) { + } + + override fun insertTopDown( + index: Int, + instance: Unit + ) { + } + + override fun move( + from: Int, + to: Int, + count: Int + ) { + } + + override fun remove( + index: Int, + count: Int + ) { + } + + override fun up() { + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt index ee03acd866..851c337e7d 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1 import app.cash.burst.Burst +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.NONE @@ -36,14 +37,18 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield import okio.ByteString +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull @@ -88,11 +93,17 @@ class RenderWorkflowInTest( } @BeforeTest - public fun setup() { + fun setup() { traces.clear() + Dispatchers.setMain(dispatcherUsed) } - @Test fun initial_rendering_is_calculated_synchronously() = runTest(dispatcherUsed) { + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test fun initial_rendering_is_calculated_synchronously() = runTestIfConfigValid { val props = MutableStateFlow("foo") val workflow = Workflow.stateless { "props: $it" } // Don't allow the workflow runtime to actually start if this is a [StandardTestDispatcher]. @@ -107,7 +118,7 @@ class RenderWorkflowInTest( assertEquals("props: foo", renderings.value.rendering) } - @Test fun initial_rendering_is_reported_through_interceptor() = runTest(dispatcherUsed) { + @Test fun initial_rendering_is_reported_through_interceptor() = runTestIfConfigValid { val props = MutableStateFlow("foo") val workflow = Workflow.stateless { "props: $it" } @@ -126,12 +137,20 @@ class RenderWorkflowInTest( interceptors = listOf(testInterceptor), runtimeConfig = runtimeConfig, workflowTracer = testTracer, - ) {} + onOutput = {}, + ) hasReportedRendering.lock() } - @Test fun initial_rendering_is_calculated_when_scope_cancelled_before_start() = - runTest(dispatcherUsed) { + @Test fun initial_rendering_is_calculated_when_scope_cancelled_before_start() { + if (COMPOSE_RUNTIME in runtimeConfig) { + // Compose can't run in a cancelled scope. It's not clear why this test is here – is this + // behavior actually a requirement or did we just want to exercise this code path? Assuming + // it's the latter, and so not worth trying to bend over backwards to get Compose to comply. + return + } + + runTestIfConfigValid { val props = MutableStateFlow("foo") val workflow = Workflow.stateless { "props: $it" } @@ -146,10 +165,11 @@ class RenderWorkflowInTest( ) {} assertEquals("props: foo", renderings.value.rendering) } + } @Test fun side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_scope_cancelled_before_start() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var sideEffectWasRan = false val workflow = Workflow.stateless { runningSideEffect("test") { @@ -159,13 +179,26 @@ class RenderWorkflowInTest( val testScope = TestScope(dispatcherUsed) testScope.cancel() - renderWorkflowIn( - workflow, - testScope, - MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = testTracer, - ) {} + if (COMPOSE_RUNTIME in runtimeConfig) { + // The compose runtime will immediately throw if the scope is already cancelled. + assertFailsWith { + renderWorkflowIn( + workflow, + testScope, + MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + } + } else { + renderWorkflowIn( + workflow, + testScope, + MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + } advanceIfStandard() assertFalse(sideEffectWasRan) @@ -173,7 +206,7 @@ class RenderWorkflowInTest( @Test fun side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_scope_cancelled_before_start() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var sideEffectWasRan = false val childWorkflow = Workflow.stateless { runningSideEffect("test") { @@ -186,19 +219,32 @@ class RenderWorkflowInTest( val testScope = TestScope(dispatcherUsed) testScope.cancel() - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = testTracer, - ) {} + if (COMPOSE_RUNTIME in runtimeConfig) { + // The compose runtime will immediately throw if the scope is already cancelled. + assertFailsWith { + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + } + } else { + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + } advanceIfStandard() assertFalse(sideEffectWasRan) } - @Test fun new_renderings_are_emitted_on_update() = runTest(dispatcherUsed) { + @Test fun new_renderings_are_emitted_on_update() = runTestIfConfigValid { val props = MutableStateFlow("foo") val workflow = Workflow.stateless { "props: $it" } val renderings = renderWorkflowIn( @@ -218,7 +264,7 @@ class RenderWorkflowInTest( assertEquals("props: bar", renderings.value.rendering) } - @Test fun new_renderings_are_emitted_to_interceptor() = runTest(dispatcherUsed) { + @Test fun new_renderings_are_emitted_to_interceptor() = runTestIfConfigValid { val props = MutableStateFlow("foo") val workflow = Workflow.stateless { "props: $it" } @@ -252,66 +298,73 @@ class RenderWorkflowInTest( // // This test is broken in 2.3.10. Burst bug? // @Test fun saves_to_and_restores_from_snapshot( // // runtime2: RuntimeOptions = NONE - // ) = runTest(dispatcherUsed) { - // // val workflow = Workflow.stateful Unit>>( - // // initialState = { _, snapshot -> - // // snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" - // // }, - // // snapshot = { state -> - // // Snapshot.write { it.writeUtf8WithLength(state) } - // // }, - // // render = { _, renderState -> - // // Pair( - // // renderState, - // // { newState -> actionSink.send(action("") { state = newState }) } - // // ) - // // } - // // ) - // // val props = MutableStateFlow(Unit) - // // val renderings = renderWorkflowIn( - // // workflow = workflow, - // // scope = backgroundScope, - // // props = props, - // // runtimeConfig = runtimeConfig, - // // workflowTracer = null, - // // ) {} - // // advanceIfStandard() + // ) { + // if (COMPOSE_RUNTIME in runtimeConfig != COMPOSE_RUNTIME in runtime2.runtimeConfig) { + // // Snapshots created by the traditional runtime and the compose runtime are not compatible. + // return + // } + // + // runTestIfConfigValid { + // // val workflow = Workflow.stateful Unit>>( + // // initialState = { _, snapshot -> + // // snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" + // // }, + // // snapshot = { state -> + // // Snapshot.write { it.writeUtf8WithLength(state) } + // // }, + // // render = { _, renderState -> + // // Pair( + // // renderState, + // // { newState -> actionSink.send(action("") { state = newState }) } + // // ) + // // } + // // ) + // // val props = MutableStateFlow(Unit) + // // val renderings = renderWorkflowIn( + // // workflow = workflow, + // // scope = backgroundScope, + // // props = props, + // // runtimeConfig = runtimeConfig, + // // workflowTracer = null, + // // ) {} + // // advanceIfStandard() // // - // // // Interact with the workflow to change the state. - // // renderings.value.rendering.let { (state, updateState) -> - // // assertEquals("initial state", state) - // // updateState("updated state") - // // } - // // advanceIfStandard() + // // //Interact with the workflow to change the state. + // // renderings.value.rendering.let { (state, updateState) -> + // // assertEquals("initial state", state) + // // updateState("updated state") + // // } + // // advanceIfStandard() // // - // // val snapshot = renderings.value.let { (rendering, snapshot) -> - // // val (state, updateState) = rendering - // // assertEquals("updated state", state) - // // updateState("ignored rendering") - // // return@let snapshot - // // } - // // advanceIfStandard() + // // val snapshot = renderings.value.let { (rendering, snapshot) -> + // // val (state, updateState) = rendering + // // assertEquals("updated state", state) + // // updateState("ignored rendering") + // // return@let snapshot + // // } + // // advanceIfStandard() // // - // // // Create a new scope to launch a second runtime to restore. - // // val restoreScope = TestScope(dispatcherUsed) - // // val restoredRenderings = - // // renderWorkflowIn( - // // workflow = workflow, - // // scope = restoreScope, - // // props = props, - // // initialSnapshot = snapshot, - // // workflowTracer = null, - // // runtimeConfig = runtime2.runtimeConfig - // // ) {} - // // advanceIfStandard() - // // assertEquals( - // // "updated state", - // // restoredRenderings.value.rendering.first - // // ) + // // //Create a new scope to launch a second runtime to restore. + // // val restoreScope = TestScope(dispatcherUsed) + // // val restoredRenderings = + // // renderWorkflowIn( + // // workflow = workflow, + // // scope = restoreScope, + // // props = props, + // // initialSnapshot = snapshot, + // // workflowTracer = null, + // // runtimeConfig = runtime2.runtimeConfig + // // ) {} + // // advanceIfStandard() + // // assertEquals( + // // "updated state", + // // restoredRenderings.value.rendering.first + // // ) + //} // } // https://github.com/square/workflow-kotlin/issues/223 - @Test fun snapshots_are_lazy() = runTest(dispatcherUsed) { + @Test fun snapshots_are_lazy() = runTestIfConfigValid { lateinit var sink: Sink var snapped = false @@ -344,7 +397,7 @@ class RenderWorkflowInTest( } advanceIfStandard() - if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + if (RENDER_ONLY_WHEN_STATE_CHANGES in runtimeConfig || COMPOSE_RUNTIME in runtimeConfig) { // we have to change state then or it won't render. sink.send("changing state") } else { @@ -352,7 +405,7 @@ class RenderWorkflowInTest( } advanceIfStandard() - if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + if (RENDER_ONLY_WHEN_STATE_CHANGES in runtimeConfig || COMPOSE_RUNTIME in runtimeConfig) { // we have to change state then or it won't render. sink.send("changing state, again") } else { @@ -373,7 +426,7 @@ class RenderWorkflowInTest( ) } - @Test fun onOutput_called_when_output_emitted() = runTest(dispatcherUsed) { + @Test fun onOutput_called_when_output_emitted() = runTestIfConfigValid { val trigger = Channel() val workflow = Workflow.stateless { runningWorker( @@ -415,7 +468,7 @@ class RenderWorkflowInTest( * a different dispatcher for the runtime. */ @Test fun onOutput_called_after_rendering_emitted() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val trigger = Channel() val workflow = Workflow.stateful( initialState = "initial", @@ -441,12 +494,13 @@ class RenderWorkflowInTest( props = MutableStateFlow(Unit), runtimeConfig = runtimeConfig, workflowTracer = testTracer, - ) { it: String -> - receivedOutputs += it - // The value of the updated rendering has already been set by the time onOutput is - // called - assertEquals(it, renderings.value.rendering) - } + onOutput = { + receivedOutputs += it + // The value of the updated rendering has already been set by the time onOutput is + // called + assertEquals(it, renderings.value.rendering) + }, + ) advanceIfStandard() assertTrue(receivedOutputs.isEmpty()) @@ -470,7 +524,7 @@ class RenderWorkflowInTest( */ @Test fun onOutput_called_after_rendering_emitted_and_collected() { if (dispatcherUsed != myStandardTestDispatcher) { - runTest(dispatcherUsed) { + runTestIfConfigValid { val trigger = Channel() val workflow = Workflow.stateful( initialState = "initial", @@ -576,7 +630,7 @@ class RenderWorkflowInTest( } @Test fun onOutput_is_not_called_when_no_output_emitted() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val workflow = Workflow.stateless { props -> props } var onOutputCalls = 0 val props = MutableStateFlow(0) @@ -608,7 +662,7 @@ class RenderWorkflowInTest( * the caller, and once to the scope. */ @Test fun exception_from_initial_render_does_not_fail_parent_scope() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val workflow = Workflow.stateless { throw ExpectedException() } @@ -626,7 +680,7 @@ class RenderWorkflowInTest( @Test fun side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_initial_render_of_root_workflow_fails() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var sideEffectWasRan = false val workflow = Workflow.stateless { runningSideEffect("test") { @@ -649,7 +703,7 @@ class RenderWorkflowInTest( @Test fun side_effects_from_initial_rendering_in_non_root_workflow_are_cancelled_when_initial_render_of_root_workflow_fails() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var sideEffectWasRan = false var cancellationException: Throwable? = null val childWorkflow = Workflow.stateless { @@ -687,7 +741,7 @@ class RenderWorkflowInTest( @Test fun side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_initial_render_of_non_root_workflow_fails() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var sideEffectWasRan = false val childWorkflow = Workflow.stateless { runningSideEffect("test") { @@ -712,7 +766,7 @@ class RenderWorkflowInTest( } @Test fun exception_from_non_initial_render_fails_parent_scope() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val trigger = CompletableDeferred() // Throws an exception when trigger is completed. val workflow = Workflow.stateful( @@ -742,7 +796,7 @@ class RenderWorkflowInTest( } @Test fun exception_from_action_fails_parent_scope() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val trigger = CompletableDeferred() // Throws an exception when trigger is completed. val workflow = Workflow.stateless { @@ -770,7 +824,7 @@ class RenderWorkflowInTest( } @Test fun cancelling_scope_cancels_runtime() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var cancellationException: Throwable? = null val workflow = Workflow.stateless { runningSideEffect(key = "test1") { @@ -800,7 +854,7 @@ class RenderWorkflowInTest( } @Test fun cancelling_scope_in_action_cancels_runtime_and_does_not_render_again() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val testScope = TestScope(dispatcherUsed) val trigger = CompletableDeferred() var renderCount = 0 @@ -837,7 +891,7 @@ class RenderWorkflowInTest( } @Test fun failing_scope_cancels_runtime() = - runTest(dispatcherUsed) { + runTestIfConfigValid { var cancellationException: Throwable? = null val workflow = Workflow.stateless { runningSideEffect(key = "failing") { @@ -860,12 +914,16 @@ class RenderWorkflowInTest( testScope.cancel(CancellationException("fail!", ExpectedException())) advanceIfStandard() - assertTrue(cancellationException is CancellationException) - assertTrue(cancellationException!!.cause is ExpectedException) + assertIs(cancellationException) + // When compose is cancelled, effects are cancelled with an exception that just says they left + // the composition, and doesn't include the cause of the cancellation. + if (COMPOSE_RUNTIME !in runtimeConfig) { + assertIs(cancellationException!!.cause) + } } @Test fun error_from_renderings_collector_does_not_fail_parent_scope() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val workflow = Workflow.stateless {} val testScope = TestScope(dispatcherUsed) val renderings = renderWorkflowIn( @@ -888,7 +946,7 @@ class RenderWorkflowInTest( } @Test fun exception_from_onOutput_fails_parent_scope() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val trigger = CompletableDeferred() // Emits a Unit when trigger is completed. val workflow = Workflow.stateless { @@ -914,7 +972,7 @@ class RenderWorkflowInTest( // https://github.com/square/workflow-kotlin/issues/224 @Test fun exceptions_from_Snapshots_do_not_fail_runtime() = - runTest(dispatcherUsed) { + runTestIfConfigValid { val workflow = Workflow.stateful( snapshot = { Snapshot.of { @@ -954,7 +1012,7 @@ class RenderWorkflowInTest( // https://github.com/square/workflow-kotlin/issues/224 @Test fun exceptions_from_renderings_equals_methods_do_not_fail_runtime() = - runTest(dispatcherUsed) { + runTestIfConfigValid { @Suppress("EqualsOrHashCode", "unused") class FailRendering(val value: Int) { override fun equals(other: Any?): Boolean { @@ -999,7 +1057,7 @@ class RenderWorkflowInTest( // https://github.com/square/workflow-kotlin/issues/224 @Test fun exceptions_from_renderings_hashCode_methods_do_not_fail_runtime() = - runTest(dispatcherUsed) { + runTestIfConfigValid { @Suppress("EqualsOrHashCode") data class FailRendering(val value: Int) { override fun hashCode(): Int { @@ -1043,7 +1101,7 @@ class RenderWorkflowInTest( @Test fun for_render_on_state_change_only_we_do_not_render_if_state_not_changed() { if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink @@ -1079,7 +1137,7 @@ class RenderWorkflowInTest( @Test fun for_render_on_state_change_only_we_report_skipped_in_interceptor() { if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink var interceptedRenderingsCount = 0 @@ -1129,7 +1187,7 @@ class RenderWorkflowInTest( @Test fun for_render_on_state_change_only_we_render_if_state_changed() { if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink @@ -1167,7 +1225,7 @@ class RenderWorkflowInTest( @Test fun for_partial_tree_rendering_we_do_not_render_nodes_if_state_not_changed_even_in_render_pass() { if (runtimeConfig.contains(PARTIAL_TREE_RENDERING)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(PARTIAL_TREE_RENDERING)) val trigger = MutableSharedFlow() @@ -1232,7 +1290,7 @@ class RenderWorkflowInTest( @Test fun for_partial_tree_rendering_we_render_nodes_if_state_changed() { if (runtimeConfig.contains(PARTIAL_TREE_RENDERING)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(PARTIAL_TREE_RENDERING)) val trigger = MutableSharedFlow() @@ -1309,7 +1367,7 @@ class RenderWorkflowInTest( CONFLATE_STALE_RENDERINGS ) ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1389,7 +1447,7 @@ class RenderWorkflowInTest( @Test fun for_conflate_we_conflate_stacked_actions_into_one_rendering() { if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) var childHandlerActionExecuted = false @@ -1473,7 +1531,7 @@ class RenderWorkflowInTest( if (CONFLATE_STALE_RENDERINGS in runtimeConfig && WORK_STEALING_DISPATCHER !in runtimeConfig ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) var childHandlerActionExecuted = false @@ -1558,7 +1616,7 @@ class RenderWorkflowInTest( if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1637,7 +1695,7 @@ class RenderWorkflowInTest( if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1737,7 +1795,7 @@ class RenderWorkflowInTest( return } - runTest(dispatcherUsed) { + runTestIfConfigValid { val workflow = Workflow.stateful(initialState = 0) { effectCount -> // Because of the WSD, this effect will be allowed to run after the render pass but before // emitting the rendering OR checking for new actions, in the CSR loop. Since it emits an @@ -1837,73 +1895,71 @@ class RenderWorkflowInTest( } @Test - fun for_drain_exclusive_we_handle_multiple_actions_in_one_render_or_not() = runTest( - dispatcherUsed - ) { - - var childActionAppliedCount = 0 - var parentRenderCount = 0 - val trigger = MutableSharedFlow() - - val childWorkflow = Workflow.stateful( - initialState = "unchanged state", - render = { renderState -> - runningWorker( - trigger.asWorker() - ) { - action("") { - state = it - childActionAppliedCount++ + fun for_drain_exclusive_we_handle_multiple_actions_in_one_render_or_not() = + runTestIfConfigValid { + var childActionAppliedCount = 0 + var parentRenderCount = 0 + val trigger = MutableSharedFlow() + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + trigger.asWorker() + ) { + action("") { + state = it + childActionAppliedCount++ + } } + renderState } - renderState - } - ) - val workflow = Workflow.stateful( - initialState = "unchanging state", - render = { renderState -> - renderChild(childWorkflow, key = "key1") { _ -> - WorkflowAction.noAction() - } - renderChild(childWorkflow, key = "key2") { _ -> - WorkflowAction.noAction() + ) + val workflow = Workflow.stateful( + initialState = "unchanging state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { _ -> + WorkflowAction.noAction() + } + renderChild(childWorkflow, key = "key2") { _ -> + WorkflowAction.noAction() + } + parentRenderCount++ + renderState } - parentRenderCount++ - renderState - } - ) - val props = MutableStateFlow(Unit) - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = testTracer, - ) { } - advanceIfStandard() + ) + val props = MutableStateFlow(Unit) + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { } + advanceIfStandard() - launch { - trigger.emit("changed state") - } - advanceIfStandard() + launch { + trigger.emit("changed state") + } + advanceIfStandard() - // 2 child actions processed. - assertEquals(2, childActionAppliedCount, "Expecting 2 child actions to be applied.") - if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) { - // and 2 parent renders - 1 initial (synchronous) and then 1 additional. - assertEquals(2, parentRenderCount, "Expecting only 2 total renders.") - } else { - // and 3 parent renders - 1 initial (synchronous) and then 1 additional for each child. - assertEquals(3, parentRenderCount, "Expecting only 3 total renders.") + // 2 child actions processed. + assertEquals(2, childActionAppliedCount, "Expecting 2 child actions to be applied.") + if (DRAIN_EXCLUSIVE_ACTIONS in runtimeConfig || COMPOSE_RUNTIME in runtimeConfig) { + // and 2 parent renders - 1 initial (synchronous) and then 1 additional. + assertEquals(2, parentRenderCount, "Expecting only 2 total renders.") + } else { + // and 3 parent renders - 1 initial (synchronous) and then 1 additional for each child. + assertEquals(3, parentRenderCount, "Expecting only 3 total renders.") + } } - } @Test fun for_drain_exclusive_and_render_only_when_state_changes_we_handle_multiple_actions_in_one_render_but_do_not_render_if_no_state_change() { if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) && runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1974,7 +2030,7 @@ class RenderWorkflowInTest( if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) && runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) ) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -2070,7 +2126,7 @@ class RenderWorkflowInTest( @Test fun for_drain_exclusive_we_do_not_handle_multiple_actions_in_one_render_if_not_exclusive() { if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) { - runTest(dispatcherUsed) { + runTestIfConfigValid { check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) var childActionAppliedCount = 0 @@ -2132,6 +2188,14 @@ class RenderWorkflowInTest( } } + private fun runTestIfConfigValid(testBody: suspend TestScope.() -> Unit) { + if (COMPOSE_RUNTIME in runtimeConfig && useUnconfined) { + // Compose runtime does not support unconfined dispatcher. + return + } + runTest(dispatcherUsed, testBody = testBody) + } + private class ExpectedException : RuntimeException() companion object { diff --git a/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt b/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt new file mode 100644 index 0000000000..a70ebfb71a --- /dev/null +++ b/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt @@ -0,0 +1,7 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = Dispatchers.Main diff --git a/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt b/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt new file mode 100644 index 0000000000..aa8bedb8a5 --- /dev/null +++ b/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt @@ -0,0 +1,6 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher + +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = TODO("Not yet implemented") diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt new file mode 100644 index 0000000000..a70ebfb71a --- /dev/null +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt @@ -0,0 +1,7 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = Dispatchers.Main diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt new file mode 100644 index 0000000000..d5df1d38c2 --- /dev/null +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.saveable.SaveableStateRegistry +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.parse +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.writeUtf8WithLength +import okio.BufferedSink +import okio.ByteString +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * A [SaveableStateRegistry] that can save and restore anything that is [Serializable]. + */ +internal class SerializableSaveableStateRegistry private constructor( + saveableStateRegistry: SaveableStateRegistry +) : SaveableStateRegistry by saveableStateRegistry { + constructor(restoredValues: Map>?) : this( + SaveableStateRegistry(restoredValues, ::canBeSavedAsSerializable) + ) + + constructor(snapshot: Snapshot) : this(snapshot.bytes.toMap()) + + fun toSnapshot(): Snapshot = Snapshot.write { sink -> + performSave().writeTo(sink) + } +} + +/** + * Checks that [value] can be stored as a [Serializable]. + */ +private fun canBeSavedAsSerializable(value: Any): Boolean { + if (value !is Serializable) return false + + // lambdas in Kotlin implement Serializable, but will crash if you really try to save them. + // we check for both Function and Serializable (see kotlin.jvm.internal.Lambda) to support + // custom user defined classes implementing Function interface. + if (value is Function<*>) return false + + return true +} + +private fun ByteString.toMap(): Map>? { + return parse { source -> + val size = source.readInt() + if (size == 0) return null + + val inputStream = ObjectInputStream(source.inputStream()) + buildMap(capacity = size) { + repeat(size) { + val key = source.readUtf8WithLength() + + @Suppress("UNCHECKED_CAST") + val arrayList = inputStream.readObject() as ArrayList + put(key, arrayList) + } + } + } +} + +private fun Map>.writeTo(sink: BufferedSink) { + // sink.writeInt(values.size) + // val outputStream = ObjectOutputStream(sink.outputStream()) + // values.forEach { (key, list) -> + // val arrayList = if (list is ArrayList) list else ArrayList(list) + // sink.writeUtf8WithLength(key) + // outputStream.writeObject(arrayList) + // outputStream.flush() + // } +} diff --git a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt index 0488ef524b..dd2dacd81f 100644 --- a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt +++ b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt @@ -1,24 +1,43 @@ package com.squareup.workflow1 -import app.cash.burst.Burst +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.COMPOSE_RUNTIME_ONLY import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.newFixedThreadPoolContext import kotlinx.coroutines.plus +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Before import java.util.concurrent.CountDownLatch import kotlin.test.Test +import kotlin.test.assertEquals -@OptIn(WorkflowExperimentalRuntime::class) -@Burst +@OptIn(WorkflowExperimentalRuntime::class, ExperimentalCoroutinesApi::class) +// @Burst class WorkflowRuntimeMultithreadingStressTest( - private val runtime: RuntimeConfigOptions.Companion.RuntimeOptions = RuntimeConfigOptions.Companion.RuntimeOptions.NONE ) { + private val runtime: RuntimeOptions = COMPOSE_RUNTIME_ONLY + + @Before + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } @OptIn(DelicateCoroutinesApi::class) @Test @@ -44,13 +63,13 @@ class WorkflowRuntimeMultithreadingStressTest( // The parent renders a bunch of these children and increments a counter every time any of them // emit an output. We use multiple children to create contention on the select with multiple // channels. - val child = Workflow.stateless { childIndex: Int -> + val child = Workflow.stateless { runningSideEffect("emitter") { repeat(emittersPerChild) { emitterIndex -> launch(start = UNDISPATCHED) { val action = action("emit-$emitterIndex") { setOutput(Unit) } startEmittingLatch.join() - repeat(emissionsPerEmitter) { emissionIndex -> + repeat(emissionsPerEmitter) { actionSink.send(action) yield() } @@ -70,6 +89,12 @@ class WorkflowRuntimeMultithreadingStressTest( return@stateful count }) + println("Thread count: $testThreadCount") + println("Child count: $childCount") + println("Emitters per child: $emittersPerChild") + println("Emissions per emitter: $emissionsPerEmitter") + println("Waiting for $totalEmissions emissions…") + val testDispatcher = newFixedThreadPoolContext(nThreads = testThreadCount, name = "test") testDispatcher.use { val renderings = renderWorkflowIn( @@ -82,17 +107,13 @@ class WorkflowRuntimeMultithreadingStressTest( // Wait for all workers to spin up. emittersReadyLatch.awaitUntilDone() - println("Thread count: $testThreadCount") - println("Child count: $childCount") - println("Emitters per child: $emittersPerChild") - println("Emissions per emitter: $emissionsPerEmitter") - println("Waiting for $totalEmissions emissions…") // Trigger an avalanche of emissions. startEmittingLatch.complete() // Wait for all workers to finish. - renderings.first { it.rendering == totalEmissions } + val finalRendering = renderings.first { it.rendering >= totalEmissions } + assertEquals(totalEmissions, finalRendering.rendering) } } } diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index 29b1a5da21..fee1a3b8ab 100644 --- a/workflow-ui/core-common/api/core-common.api +++ b/workflow-ui/core-common/api/core-common.api @@ -51,6 +51,7 @@ public final class com/squareup/workflow1/ui/EnvironmentScreen : com/squareup/wo public final class com/squareup/workflow1/ui/EnvironmentScreenKt { public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/EnvironmentScreen; public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lkotlin/Pair;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/EnvironmentScreen; public static synthetic fun withEnvironment$default (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/EnvironmentScreen; public static final fun withRegistry (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/EnvironmentScreen; } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt index 4626a7ce2a..d0b825e737 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt @@ -63,3 +63,10 @@ public fun Screen.withEnvironment( public fun Screen.withEnvironment( entry: Pair, T> ): EnvironmentScreen<*> = withEnvironment(ViewEnvironment.EMPTY + entry) + +public fun Screen.withEnvironment( + envTransform: (ViewEnvironment) -> ViewEnvironment +): EnvironmentScreen<*> = when (this) { + is EnvironmentScreen<*> -> EnvironmentScreen(content, envTransform(environment)) + else -> EnvironmentScreen(this, envTransform(ViewEnvironment.EMPTY)) +} From b894e52c58c5f80d62681d819842cd8fcc8c1772 Mon Sep 17 00:00:00 2001 From: zach-klippenstein <101754+zach-klippenstein@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:49:53 +0000 Subject: [PATCH 02/13] Apply changes from dependencyGuardBaseline --refresh-dependencies Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../dependencies/releaseRuntimeClasspath.txt | 48 ++++++++++++------- .../dependencies/runtimeClasspath.txt | 12 +++++ .../dependencies/androidRuntimeClasspath.txt | 48 ++++++++++++------- .../dependencies/jsRuntimeClasspath.txt | 9 ++++ .../dependencies/jvmMainRuntimeClasspath.txt | 31 ++++++------ .../dependencies/jvmRuntimeClasspath.txt | 12 +++++ .../dependencies/runtimeClasspath.txt | 12 +++++ .../dependencies/releaseRuntimeClasspath.txt | 48 ++++++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 48 ++++++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 16 +++++++ .../dependencies/releaseRuntimeClasspath.txt | 16 +++++++ .../dependencies/releaseRuntimeClasspath.txt | 48 ++++++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 48 ++++++++++++------- 13 files changed, 285 insertions(+), 111 deletions(-) diff --git a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt index 805293158d..9e5d0a72b3 100644 --- a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.4.4 androidx.collection:collection-ktx:1.4.4 androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 @@ -55,11 +55,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt index 0608390b3e..e3a3bf43bb 100644 --- a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt +++ b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt @@ -1,10 +1,22 @@ +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection:1.4.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 diff --git a/workflow-runtime/dependencies/androidRuntimeClasspath.txt b/workflow-runtime/dependencies/androidRuntimeClasspath.txt index 805293158d..9e5d0a72b3 100644 --- a/workflow-runtime/dependencies/androidRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/androidRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.4.4 androidx.collection:collection-ktx:1.4.4 androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 @@ -55,11 +55,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-runtime/dependencies/jsRuntimeClasspath.txt b/workflow-runtime/dependencies/jsRuntimeClasspath.txt index a5ce631af6..a734838850 100644 --- a/workflow-runtime/dependencies/jsRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jsRuntimeClasspath.txt @@ -1,10 +1,19 @@ com.squareup.okio:okio-js:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.compose.annotation-internal:annotation-js:1.7.3 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection-js:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-js:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable-js:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-dom-api-compat:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-js:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 org.jetbrains.kotlin:kotlinx-atomicfu-runtime:2.0.0 org.jetbrains.kotlinx:atomicfu-js:0.25.0 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 diff --git a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt index 043be4788c..e3a3bf43bb 100644 --- a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt @@ -1,22 +1,23 @@ -com.fasterxml.jackson.core:jackson-annotations:2.12.7 -com.fasterxml.jackson.core:jackson-core:2.12.7 -com.fasterxml.jackson.core:jackson-databind:2.12.7.1 -com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7 -com.fasterxml.jackson:jackson-bom:2.12.7 -org.freemarker:freemarker:2.3.32 -org.jetbrains.dokka:all-modules-page-plugin:2.0.0 -org.jetbrains.dokka:analysis-markdown:2.0.0 -org.jetbrains.dokka:dokka-base:2.0.0 -org.jetbrains.dokka:templating-plugin:2.0.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection:1.4.0 +com.squareup.okio:okio-jvm:3.3.0 +com.squareup.okio:okio:3.3.0 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 -org.jetbrains.kotlin:kotlin-reflect:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 -org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1 org.jetbrains:annotations:23.0.0 -org.jetbrains:markdown-jvm:0.7.3 -org.jetbrains:markdown:0.7.3 -org.jsoup:jsoup:1.16.1 diff --git a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt index ba73455712..2164483833 100644 --- a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt @@ -1,9 +1,21 @@ +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection:1.4.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 diff --git a/workflow-testing/dependencies/runtimeClasspath.txt b/workflow-testing/dependencies/runtimeClasspath.txt index 18e923bd3e..6ba64c93d4 100644 --- a/workflow-testing/dependencies/runtimeClasspath.txt +++ b/workflow-testing/dependencies/runtimeClasspath.txt @@ -1,13 +1,25 @@ +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection:1.4.0 app.cash.turbine:turbine-jvm:1.0.0 app.cash.turbine:turbine:1.0.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-reflect:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 diff --git a/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt b/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt index 13bd74ae57..71b2c42a9e 100644 --- a/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-ktx:1.5.0 androidx.collection:collection:1.5.0 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 @@ -56,11 +56,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-tracing/dependencies/releaseRuntimeClasspath.txt b/workflow-tracing/dependencies/releaseRuntimeClasspath.txt index 7b7703041c..2d5ae4bf13 100644 --- a/workflow-tracing/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing/dependencies/releaseRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-ktx:1.5.0 androidx.collection:collection:1.5.0 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 @@ -55,11 +55,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt index fbdb7c3817..d97c1713d3 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -76,11 +76,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt index 5b75d1ff38..a03c873f4c 100644 --- a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt @@ -70,11 +70,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 80acf834d5..cfab45c723 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.4.4 androidx.collection:collection-ktx:1.4.4 androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 @@ -64,11 +64,27 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index c1ed820e87..1efbc0c17b 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -9,22 +9,22 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.4.4 androidx.collection:collection-ktx:1.4.4 androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.compose.runtime:runtime-android:1.7.6 +androidx.compose.runtime:runtime-saveable-android:1.7.6 +androidx.compose.runtime:runtime-saveable:1.7.6 +androidx.compose.runtime:runtime:1.7.6 +androidx.compose.ui:ui-android:1.7.6 +androidx.compose.ui:ui-geometry-android:1.7.6 +androidx.compose.ui:ui-geometry:1.7.6 +androidx.compose.ui:ui-graphics-android:1.7.6 +androidx.compose.ui:ui-graphics:1.7.6 +androidx.compose.ui:ui-text-android:1.7.6 +androidx.compose.ui:ui-text:1.7.6 +androidx.compose.ui:ui-unit-android:1.7.6 +androidx.compose.ui:ui-unit:1.7.6 +androidx.compose.ui:ui-util-android:1.7.6 +androidx.compose.ui:ui-util:1.7.6 +androidx.compose.ui:ui:1.7.6 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 @@ -66,11 +66,27 @@ com.squareup.curtains:curtains:1.2.2 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 com.squareup.radiography:radiography:2.4.1 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 +org.jetbrains.compose.annotation-internal:annotation:1.7.3 +org.jetbrains.compose.collection-internal:collection:1.7.3 +org.jetbrains.compose.runtime:runtime-saveable:1.7.3 +org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.compose.ui:ui-geometry:1.7.3 +org.jetbrains.compose.ui:ui-graphics:1.7.3 +org.jetbrains.compose.ui:ui-text:1.7.3 +org.jetbrains.compose.ui:ui-unit:1.7.3 +org.jetbrains.compose.ui:ui-util:1.7.3 +org.jetbrains.compose.ui:ui:1.7.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 +org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 From 57d12ca9c3554ab258732bd538267454fc7115b2 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 16:57:53 -0700 Subject: [PATCH 03/13] Bump runTest timeout in WorkflowRuntimeMicrobenchmark to 10m The default 1-minute runTest timeout fires for the slowest combinations on physical devices, surfacing as UncompletedCoroutinesError instead of a real benchmark result. The benchmark body itself is bounded by measureRepeated, so widening the test timeout just lets the slow cases finish. Without this, 14 of 84 combinations timed out on a Galaxy A25. --- .../runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt index be9510dfeb..ce37d0e2d9 100644 --- a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt +++ b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt @@ -32,6 +32,7 @@ import org.junit.Rule import org.junit.Test import kotlin.math.pow import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes /** The microbenchmarks take a while to run, so we only run with a subset of runtime configs. */ @Suppress("unused") @@ -63,6 +64,11 @@ class WorkflowRuntimeMicrobenchmark( const val WideSiblingCount = 250 const val RememberEntryCount = 250 const val StableHandlerCount = 250 + + // The default 1m runTest timeout fires for the slowest combinations on physical devices, + // surfacing as UncompletedCoroutinesError instead of a real benchmark result. The benchmark + // body itself is bounded by `measureRepeated` so this just lets it finish. + val BenchmarkRunTestTimeout = 10.minutes } @get:Rule val benchmarkRule = BenchmarkRule() @@ -263,7 +269,7 @@ class WorkflowRuntimeMicrobenchmark( testProps: PropsT, expectedSetupRendering: Int, expectedTestRendering: Int, - ) = runTest { + ) = runTest(timeout = BenchmarkRunTestTimeout) { val props = MutableStateFlow(setupProps) val workflowJob = Job(parent = coroutineContext.job) val renderings = renderWorkflowIn( @@ -296,7 +302,7 @@ class WorkflowRuntimeMicrobenchmark( private fun benchmarkWorkflowStateChange( testState: (setStateForChild: (index: Int, newState: Int) -> Unit) -> Unit, expectedTestRendering: Int, - ) = runTest { + ) = runTest(timeout = BenchmarkRunTestTimeout) { val actionSinks = arrayOfNulls>?>(treeShape.leafCount) val workflow = BenchmarkWorkflowRoot( treeShape = treeShape, From 931de9fc2e6a0cd3ce73861b7d136b64ff2c9feb Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 16:58:10 -0700 Subject: [PATCH 04/13] Enable per-renderWorkflow Compose skipping Wrap renderWorkflow in rememberSkippableAndRestartableComposable, keyed on (props, onOutput). When neither key changes and the producer hasn't been invalidated by a state read, the entire render of that workflow session is skipped and the cached rendering is returned. State-change invalidation still flows through the producer's restart group, so internal updates re-run the producer in the same frame. Also switch the cache-update check in RememberComposable from equals to identity. The pre-existing equals comparison violated the workflow runtime contract that rendering equals/hashCode are allowed to throw, breaking exceptions_from_renderings_equals_methods_do_not_fail_runtime. For workflow renderings, equals-based dedup is meaningless anyway since parent skipping already handles identity dedup; the only remaining job of the cache check is "did the producer run? if so, take its output". Microbenchmark deltas (Galaxy A25, ShallowBushyTree / SquareishTree): - rerenderSingleSiblingByPropsChange: -67% / -47% - rerenderSingleSiblingViaStateChange: -58% / -87% - wideSiblingKeys_rerenderSingleSiblingByPropsChange: -59% / resolved - initialRenderNewSibling: -55% / -40% - tearDownSingleSibling: -25% / resolved With small regressions on initial-render-everything cases (around +11% to +71%) where the wrapper's overhead can't amortize. --- .../internal/compose/RememberComposable.kt | 44 ++++++++--------- .../internal/compose/RenderWorkflow.kt | 49 ++++++++++--------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt index 3f41289c80..8ce087be8c 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt @@ -322,19 +322,18 @@ internal fun rememberSkippableComposableImpl( // region Update cache // Cache the return value in case we skipped above. Composer APIs require always reading the value // first, and then calling updateRememberedValue the first time or optionally on subsequent - // recompositions. + // recompositions. Identity comparison is intentional: the values cached here may be workflow + // renderings whose `equals` is allowed to throw or have side effects, so we must never call + // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys + // above; the only remaining job here is "did the producer run? if so, take its output". val oldValue = composer.rememberedValue() - val returnValue = if ( - oldValue === Composer.Empty || - (newValue !== Composer.Empty && newValue != oldValue) - ) { - // Update the cache. - if (newValue === Composer.Empty) error("No new value was calculated.") + val returnValue = if (newValue === Composer.Empty) { + // Producer was skipped, return from the cache. + oldValue + } else { + // Producer ran, update the cache and return its new value. composer.updateRememberedValue(newValue) newValue - } else { - // Producer was skipped (or returned the same value), return from the cache. - oldValue } // endregion @@ -392,26 +391,25 @@ internal fun rememberSkippableAndRestartableComposableImpl( // region Update cache // Cache the return value in case we skipped above. Composer APIs require always reading the value // first, and then calling updateRememberedValue the first time or optionally on subsequent - // recompositions. + // recompositions. Identity comparison is intentional: the values cached here may be workflow + // renderings whose `equals` is allowed to throw or have side effects, so we must never call + // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys + // above; the only remaining job here is "did the producer run? if so, take its output". val oldValue = composer.rememberedValue() - val returnValue = if ( - oldValue === Composer.Empty || - (newValue !== Composer.Empty && newValue != oldValue) - ) { - // Update the cache. - if (newValue === Composer.Empty) error("No new value was calculated.") + val returnValue = if (newValue === Composer.Empty) { + // Producer was skipped, return from the cache. + oldValue + } else { + // Producer ran, update the cache and return its new value. composer.updateRememberedValue(newValue) - // When we're recomposed directly, we obviously can't return returnValue to the original caller, so - // just invalidate it instead. It will eventually recompose after we're done in the same frame, and - // when it does so it should hit the cache (unless the caller passes a new producer). + // When we're recomposed directly, we obviously can't return returnValue to the original caller, + // so just invalidate it instead. It will eventually recompose after we're done in the same frame, + // and when it does so it should hit the cache (unless the caller passes a new producer). if (invalidateCallerOnNewValue) { callerRecomposeScope.invalidate() } newValue - } else { - // Producer was skipped (or returned the same value), return from the cache. - oldValue } // endregion diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt index d09671e549..d9a8b07245 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -40,29 +40,30 @@ internal fun renderWorkflow( // The lifetime of the workflow session is tied to the workflow.identifier, but we don't key on it // here since it's already keyed from ComposeRenderContext. - // TODO Skipping without restarting doesn't work, since we have no way to know whether the state - // changed and we need to recompose anyway. I think the only way to get this information is - // from the changed parameter that the compiler generates for this composable, but we don't - // have any way to access that. - // TODO Skipping *with* restarting seems to work, but it requires allocating a composable lambda - // for the content and the trampolining is weird, so I'm leaving it disabled while I iron out - // the rest of the kinks. - // // Skip re-rendering when possible but force recompose when new props or output handler are - // passed. - // rememberSkippableComposable(props, onOutput) { - // rememberSkippableAndRestartableComposable(props, onOutput) { + // Skip re-rendering when possible, but force recompose when new props or onOutput arrive. + // We use the skippable+restartable variant so internal state-change invalidations trigger a fresh + // call to the producer lambda within the same restart group. + // + // Notes on the previous TODOs (now resolved by enabling this path): + // - Plain "skipping without restarting" can't work for us: we have no access to the compiler- + // generated $changed parameter, so we can't tell whether stale-but-equal keys mean we should + // skip. The restartable variant sidesteps this by giving each call its own restart group whose + // invalidation is what we already drive from action application. + // - The "trampolining" cost is one captured composable lambda per renderWorkflow call. The + // benchmark deltas justify that allocation. + return rememberSkippableAndRestartableComposable(key1 = props, key2 = onOutput) { + val baseContext = rememberComposeRenderContext( + workflow = workflow, + props = props, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) - val baseContext = rememberComposeRenderContext( - workflow = workflow, - props = props, - onOutput = onOutput, - config = config, - parentSession = parentSession, - renderKey = renderKey, - ) - - // TODO this feels weird to have outside the context, should it be moved in too? Should this whole - // function be moved into the context? I think unit tests will be the only real forcing function, - // so let's write some tests and see how it works. - return baseContext.renderSelf(props) + // TODO this feels weird to have outside the context, should it be moved in too? Should this + // whole function be moved into the context? I think unit tests will be the only real forcing + // function, so let's write some tests and see how it works. + baseContext.renderSelf(props) + } } From 5dcfbe46ed92527209c6381f8960ebde79f0e24d Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 16:58:28 -0700 Subject: [PATCH 05/13] Upgrade Compose Multiplatform 1.7.3 -> 1.10.3 + BOM 2026.04.01 - jetbrains-compose-plugin: 1.7.3 -> 1.10.3 - androidx-compose-bom: 2025.03.01 -> 2026.04.01 - jdk-target: 1.8 -> 11 Compose Multiplatform 1.8+ requires JVM 11+ bytecode. The project's toolchain was already 17, so bumping the target to 11 is mostly a formality for downstream consumers. Two unrelated Compose 1.10 deprecations are silenced at file scope so this benchmark experiment doesn't get tangled up in their cleanup: - rememberSaveable(key = ...) is deprecated (RenderAsState.kt) - LocalSavedStateRegistryOwner moved to savedstate-compose (ScreenComposableFactory.kt) And one unrelated Android deprecation in a sample (SampleLauncherApp). Microbenchmark deltas (Compose runtime, on top of skipping): - wideSiblingKeys_initialRenderAllChildren: -8% / -13% - remember-heavy & stable-handlers cases: -1% to -8% - everything else: within +/-5% Most of the remaining headroom turned out to already be captured by skipping; the runtime-version bump is a modest additional win. --- gradle/libs.versions.toml | 6 +++--- .../squareup/sample/compose/launcher/SampleLauncherApp.kt | 2 ++ .../java/com/squareup/workflow1/ui/compose/RenderAsState.kt | 2 ++ .../workflow1/ui/compose/ScreenComposableFactory.kt | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 000d82870e..90e653b256 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ compileSdk = "36" minSdk = "24" targetSdk = "33" -jdk-target = "1.8" +jdk-target = "11" jdk-toolchain = "17" androidx-activity = "1.8.2" @@ -15,7 +15,7 @@ androidx-benchmark = "1.3.4" androidx-cardview = "1.0.0" androidx-collection = "1.5.0" # see https://developer.android.com/jetpack/compose/bom/bom-mapping -androidx-compose-bom = "2025.03.01" +androidx-compose-bom = "2026.04.01" androidx-constraintlayout = "2.1.4" androidx-core = "1.13.1" androidx-fragment = "1.8.5" @@ -54,7 +54,7 @@ groovy = "3.0.9" jUnit = "4.13.2" java-diff-utils = "4.12" javaParser = "3.24.0" -jetbrains-compose-plugin = "1.7.3" +jetbrains-compose-plugin = "1.10.3" kgx = "0.1.12" kotest = "5.1.0" # Keep this in sync with what is hard-coded in build-logic/settings.gradle.kts as that is upstream diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt index 71a9dae666..d34cbcaf6e 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // ContextCompat.startActivity overload deprecation; sample code. + package com.squareup.sample.compose.launcher import android.content.Intent diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt index d91019f11e..89acfb009f 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // rememberSaveable(key = …) deprecation; cleanup tracked separately. + package com.squareup.workflow1.ui.compose import androidx.annotation.VisibleForTesting diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt index 54d0da49aa..4e2b0c0d3c 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // LocalSavedStateRegistryOwner moved; migration tracked separately. + package com.squareup.workflow1.ui.compose import android.content.Context From e1888a463d7e78dd29fefb4469fb78a0f37bc535 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 16:58:35 -0700 Subject: [PATCH 06/13] Refresh dependencyGuard baselines for Compose 1.10.3 Run of dependencyGuardBaseline --refresh-dependencies after the Compose upgrade. Picks up the new transitive deps from Compose Multiplatform 1.10.3 + AndroidX Compose BOM 2026.04.01. --- dependencies/classpath.txt | 5 +- .../dependencies/releaseRuntimeClasspath.txt | 142 ++++++++------- .../dependencies/runtimeClasspath.txt | 48 ++++-- .../dependencies/androidRuntimeClasspath.txt | 142 ++++++++------- .../dependencies/jsRuntimeClasspath.txt | 48 ++++-- .../dependencies/jvmMainRuntimeClasspath.txt | 48 ++++-- .../dependencies/jvmRuntimeClasspath.txt | 48 ++++-- .../dependencies/runtimeClasspath.txt | 48 ++++-- .../dependencies/releaseRuntimeClasspath.txt | 130 ++++++++------ .../dependencies/releaseRuntimeClasspath.txt | 132 ++++++++------ .../dependencies/releaseRuntimeClasspath.txt | 161 ++++++++++-------- .../dependencies/releaseRuntimeClasspath.txt | 149 +++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 139 ++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 139 ++++++++------- 14 files changed, 838 insertions(+), 541 deletions(-) diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index daddcdd3f6..59378ae7e8 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -141,8 +141,9 @@ org.codehaus.woodstox:stax2-api:4.2.1 org.glassfish.jaxb:jaxb-runtime:2.3.2 org.glassfish.jaxb:txw2:2.3.2 org.jdom:jdom2:2.0.6 -org.jetbrains.compose:compose-gradle-plugin:1.7.3 -org.jetbrains.compose:org.jetbrains.compose.gradle.plugin:1.7.3 +org.jetbrains.compose.hot-reload:hot-reload-gradle-plugin:1.0.0 +org.jetbrains.compose:compose-gradle-plugin:1.10.3 +org.jetbrains.compose:org.jetbrains.compose.gradle.plugin:1.10.3 org.jetbrains.dokka:dokka-core:2.0.0 org.jetbrains.dokka:dokka-gradle-plugin:2.0.0 org.jetbrains.dokka:org.jetbrains.dokka.gradle.plugin:2.0.0 diff --git a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt index 9e5d0a72b3..3391a043da 100644 --- a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt @@ -1,83 +1,109 @@ androidx.activity:activity-ktx:1.7.0 androidx.activity:activity:1.7.0 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.12.0 -androidx.core:core:1.12.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.documentfile:documentfile:1.0.0 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt index e3a3bf43bb..88b7ff1586 100644 --- a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt +++ b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt @@ -1,23 +1,47 @@ -androidx.annotation:annotation-jvm:1.8.0 -androidx.annotation:annotation:1.8.0 -androidx.collection:collection-jvm:1.4.0 -androidx.collection:collection:1.4.0 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.arch.core:core-common:2.2.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-annotation-jvm:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.savedstate:savedstate-compose-desktop:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-desktop:1.3.3 +androidx.savedstate:savedstate:1.3.3 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.runtime:runtime-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-runtime/dependencies/androidRuntimeClasspath.txt b/workflow-runtime/dependencies/androidRuntimeClasspath.txt index 9e5d0a72b3..3391a043da 100644 --- a/workflow-runtime/dependencies/androidRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/androidRuntimeClasspath.txt @@ -1,83 +1,109 @@ androidx.activity:activity-ktx:1.7.0 androidx.activity:activity:1.7.0 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.12.0 -androidx.core:core:1.12.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.documentfile:documentfile:1.0.0 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-runtime/dependencies/jsRuntimeClasspath.txt b/workflow-runtime/dependencies/jsRuntimeClasspath.txt index a734838850..b243abd794 100644 --- a/workflow-runtime/dependencies/jsRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jsRuntimeClasspath.txt @@ -1,19 +1,47 @@ +androidx.annotation:annotation-js:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.collection:collection-js:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-annotation-js:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-js:1.10.5 +androidx.compose.runtime:runtime-saveable-js:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.lifecycle:lifecycle-common-js:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-js:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-js:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.savedstate:savedstate-compose-js:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-js:1.3.3 +androidx.savedstate:savedstate:1.3.3 com.squareup.okio:okio-js:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.compose.annotation-internal:annotation-js:1.7.3 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection-js:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-js:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable-js:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common-js:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-js:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-js:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose-js:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-js:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.runtime:runtime-js:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable-js:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-dom-api-compat:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-js:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 org.jetbrains.kotlin:kotlinx-atomicfu-runtime:2.0.0 -org.jetbrains.kotlinx:atomicfu-js:0.25.0 -org.jetbrains.kotlinx:atomicfu:0.23.2 +org.jetbrains.kotlinx:atomicfu-js:0.27.0 +org.jetbrains.kotlinx:atomicfu:0.27.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-js:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 diff --git a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt index e3a3bf43bb..88b7ff1586 100644 --- a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt @@ -1,23 +1,47 @@ -androidx.annotation:annotation-jvm:1.8.0 -androidx.annotation:annotation:1.8.0 -androidx.collection:collection-jvm:1.4.0 -androidx.collection:collection:1.4.0 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.arch.core:core-common:2.2.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-annotation-jvm:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.savedstate:savedstate-compose-desktop:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-desktop:1.3.3 +androidx.savedstate:savedstate:1.3.3 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.runtime:runtime-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt index 2164483833..5ef2e77dea 100644 --- a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt @@ -1,22 +1,46 @@ -androidx.annotation:annotation-jvm:1.8.0 -androidx.annotation:annotation:1.8.0 -androidx.collection:collection-jvm:1.4.0 -androidx.collection:collection:1.4.0 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.arch.core:core-common:2.2.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-annotation-jvm:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.savedstate:savedstate-compose-desktop:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-desktop:1.3.3 +androidx.savedstate:savedstate:1.3.3 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.runtime:runtime-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-testing/dependencies/runtimeClasspath.txt b/workflow-testing/dependencies/runtimeClasspath.txt index 6ba64c93d4..4cbe8c1e20 100644 --- a/workflow-testing/dependencies/runtimeClasspath.txt +++ b/workflow-testing/dependencies/runtimeClasspath.txt @@ -1,28 +1,52 @@ -androidx.annotation:annotation-jvm:1.8.0 -androidx.annotation:annotation:1.8.0 -androidx.collection:collection-jvm:1.4.0 -androidx.collection:collection:1.4.0 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.arch.core:core-common:2.2.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-annotation-jvm:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable-desktop:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-desktop:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.savedstate:savedstate-compose-desktop:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-desktop:1.3.3 +androidx.savedstate:savedstate:1.3.3 app.cash.turbine:turbine-jvm:1.0.0 app.cash.turbine:turbine:1.0.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable-desktop:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.runtime:runtime-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-reflect:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt b/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt index 71b2c42a9e..8122fd2d31 100644 --- a/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt @@ -9,76 +9,102 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-ktx:1.5.0 androidx.collection:collection:1.5.0 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.12.0 -androidx.core:core:1.12.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.documentfile:documentfile:1.0.0 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 androidx.tracing:tracing-ktx:1.2.0 androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-tracing/dependencies/releaseRuntimeClasspath.txt b/workflow-tracing/dependencies/releaseRuntimeClasspath.txt index 2d5ae4bf13..3391a043da 100644 --- a/workflow-tracing/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing/dependencies/releaseRuntimeClasspath.txt @@ -9,75 +9,101 @@ androidx.autofill:autofill:1.0.0 androidx.collection:collection-jvm:1.5.0 androidx.collection:collection-ktx:1.5.0 androidx.collection:collection:1.5.0 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.12.0 -androidx.core:core:1.12.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.documentfile:documentfile:1.0.0 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt index d97c1713d3..0ff3b5adcb 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -2,103 +2,120 @@ androidx.activity:activity-compose:1.8.2 androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.animation:animation-android:1.7.8 -androidx.compose.animation:animation-core-android:1.7.8 -androidx.compose.animation:animation-core:1.7.8 -androidx.compose.animation:animation:1.7.8 -androidx.compose.foundation:foundation-android:1.7.8 -androidx.compose.foundation:foundation-layout-android:1.7.8 -androidx.compose.foundation:foundation-layout:1.7.8 -androidx.compose.foundation:foundation:1.7.8 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-tooling-preview-android:1.7.8 -androidx.compose.ui:ui-tooling-preview:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose.ui:ui:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-android:1.11.0 +androidx.compose.animation:animation-core-android:1.11.0 +androidx.compose.animation:animation-core:1.11.0 +androidx.compose.animation:animation:1.11.0 +androidx.compose.foundation:foundation-android:1.11.0 +androidx.compose.foundation:foundation-layout-android:1.11.0 +androidx.compose.foundation:foundation-layout:1.11.0 +androidx.compose.foundation:foundation:1.11.0 +androidx.compose.runtime:runtime-android:1.11.0 +androidx.compose.runtime:runtime-annotation-android:1.11.0 +androidx.compose.runtime:runtime-annotation:1.11.0 +androidx.compose.runtime:runtime-retain-android:1.11.0 +androidx.compose.runtime:runtime-retain:1.11.0 +androidx.compose.runtime:runtime-saveable-android:1.11.0 +androidx.compose.runtime:runtime-saveable:1.11.0 +androidx.compose.runtime:runtime:1.11.0 +androidx.compose.ui:ui-android:1.11.0 +androidx.compose.ui:ui-geometry-android:1.11.0 +androidx.compose.ui:ui-geometry:1.11.0 +androidx.compose.ui:ui-graphics-android:1.11.0 +androidx.compose.ui:ui-graphics:1.11.0 +androidx.compose.ui:ui-text-android:1.11.0 +androidx.compose.ui:ui-text:1.11.0 +androidx.compose.ui:ui-tooling-preview-android:1.11.0 +androidx.compose.ui:ui-tooling-preview:1.11.0 +androidx.compose.ui:ui-unit-android:1.11.0 +androidx.compose.ui:ui-unit:1.11.0 +androidx.compose.ui:ui-util-android:1.11.0 +androidx.compose.ui:ui-util:1.11.0 +androidx.compose.ui:ui:1.11.0 +androidx.compose:compose-bom:2026.04.01 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.13.1 -androidx.core:core:1.13.1 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 -androidx.emoji2:emoji2:1.3.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-livedata:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 -androidx.transition:transition:1.5.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt index a03c873f4c..288ee60264 100644 --- a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt @@ -2,97 +2,114 @@ androidx.activity:activity-compose:1.8.2 androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.animation:animation-core-android:1.7.8 -androidx.compose.animation:animation-core:1.7.8 -androidx.compose.foundation:foundation-layout-android:1.7.8 -androidx.compose.foundation:foundation-layout:1.7.8 -androidx.compose.runtime:runtime-android:1.7.8 -androidx.compose.runtime:runtime-saveable-android:1.7.8 -androidx.compose.runtime:runtime-saveable:1.7.8 -androidx.compose.runtime:runtime:1.7.8 -androidx.compose.ui:ui-android:1.7.8 -androidx.compose.ui:ui-geometry-android:1.7.8 -androidx.compose.ui:ui-geometry:1.7.8 -androidx.compose.ui:ui-graphics-android:1.7.8 -androidx.compose.ui:ui-graphics:1.7.8 -androidx.compose.ui:ui-text-android:1.7.8 -androidx.compose.ui:ui-text:1.7.8 -androidx.compose.ui:ui-unit-android:1.7.8 -androidx.compose.ui:ui-unit:1.7.8 -androidx.compose.ui:ui-util-android:1.7.8 -androidx.compose.ui:ui-util:1.7.8 -androidx.compose.ui:ui:1.7.8 -androidx.compose:compose-bom:2025.03.01 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-core-android:1.11.0 +androidx.compose.animation:animation-core:1.11.0 +androidx.compose.foundation:foundation-layout-android:1.11.0 +androidx.compose.foundation:foundation-layout:1.11.0 +androidx.compose.runtime:runtime-android:1.11.0 +androidx.compose.runtime:runtime-annotation-android:1.11.0 +androidx.compose.runtime:runtime-annotation:1.11.0 +androidx.compose.runtime:runtime-retain-android:1.11.0 +androidx.compose.runtime:runtime-retain:1.11.0 +androidx.compose.runtime:runtime-saveable-android:1.11.0 +androidx.compose.runtime:runtime-saveable:1.11.0 +androidx.compose.runtime:runtime:1.11.0 +androidx.compose.ui:ui-android:1.11.0 +androidx.compose.ui:ui-geometry-android:1.11.0 +androidx.compose.ui:ui-geometry:1.11.0 +androidx.compose.ui:ui-graphics-android:1.11.0 +androidx.compose.ui:ui-graphics:1.11.0 +androidx.compose.ui:ui-text-android:1.11.0 +androidx.compose.ui:ui-text:1.11.0 +androidx.compose.ui:ui-unit-android:1.11.0 +androidx.compose.ui:ui-unit:1.11.0 +androidx.compose.ui:ui-util-android:1.11.0 +androidx.compose.ui:ui-util:1.11.0 +androidx.compose.ui:ui:1.11.0 +androidx.compose:compose-bom:2026.04.01 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.13.1 -androidx.core:core:1.13.1 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-livedata:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 -androidx.transition:transition:1.5.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index cfab45c723..289102f604 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -1,92 +1,109 @@ androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.13.1 -androidx.core:core:1.13.1 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-livedata:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 -androidx.transition:transition:1.5.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index 1efbc0c17b..c6064e4dbd 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -1,94 +1,111 @@ androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 androidx.annotation:annotation-experimental:1.4.1 -androidx.annotation:annotation-jvm:1.8.1 -androidx.annotation:annotation:1.8.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection-jvm:1.4.4 -androidx.collection:collection-ktx:1.4.4 -androidx.collection:collection:1.4.4 -androidx.compose.runtime:runtime-android:1.7.6 -androidx.compose.runtime:runtime-saveable-android:1.7.6 -androidx.compose.runtime:runtime-saveable:1.7.6 -androidx.compose.runtime:runtime:1.7.6 -androidx.compose.ui:ui-android:1.7.6 -androidx.compose.ui:ui-geometry-android:1.7.6 -androidx.compose.ui:ui-geometry:1.7.6 -androidx.compose.ui:ui-graphics-android:1.7.6 -androidx.compose.ui:ui-graphics:1.7.6 -androidx.compose.ui:ui-text-android:1.7.6 -androidx.compose.ui:ui-text:1.7.6 -androidx.compose.ui:ui-unit-android:1.7.6 -androidx.compose.ui:ui-unit:1.7.6 -androidx.compose.ui:ui-util-android:1.7.6 -androidx.compose.ui:ui-util:1.7.6 -androidx.compose.ui:ui:1.7.6 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.runtime:runtime-android:1.10.5 +androidx.compose.runtime:runtime-annotation-android:1.10.5 +androidx.compose.runtime:runtime-annotation:1.10.5 +androidx.compose.runtime:runtime-retain-android:1.10.5 +androidx.compose.runtime:runtime-retain:1.10.5 +androidx.compose.runtime:runtime-saveable-android:1.10.5 +androidx.compose.runtime:runtime-saveable:1.10.5 +androidx.compose.runtime:runtime:1.10.5 +androidx.compose.ui:ui-android:1.10.5 +androidx.compose.ui:ui-geometry-android:1.10.5 +androidx.compose.ui:ui-geometry:1.10.5 +androidx.compose.ui:ui-graphics-android:1.10.5 +androidx.compose.ui:ui-graphics:1.10.5 +androidx.compose.ui:ui-text-android:1.10.5 +androidx.compose.ui:ui-text:1.10.5 +androidx.compose.ui:ui-unit-android:1.10.5 +androidx.compose.ui:ui-unit:1.10.5 +androidx.compose.ui:ui-util-android:1.10.5 +androidx.compose.ui:ui-util:1.10.5 +androidx.compose.ui:ui:1.10.5 androidx.concurrent:concurrent-futures:1.1.0 -androidx.core:core-ktx:1.13.1 -androidx.core:core:1.13.1 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 -androidx.emoji2:emoji2:1.2.0 +androidx.emoji2:emoji2:1.4.0 androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 -androidx.lifecycle:lifecycle-common-jvm:2.8.7 -androidx.lifecycle:lifecycle-common:2.8.7 -androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 -androidx.lifecycle:lifecycle-livedata-core:2.8.7 -androidx.lifecycle:lifecycle-livedata:2.8.7 -androidx.lifecycle:lifecycle-process:2.8.7 -androidx.lifecycle:lifecycle-runtime-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-compose:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 -androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 -androidx.lifecycle:lifecycle-runtime:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 -androidx.lifecycle:lifecycle-viewmodel:2.8.7 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 -androidx.profileinstaller:profileinstaller:1.3.1 -androidx.savedstate:savedstate-ktx:1.2.1 -androidx.savedstate:savedstate:1.2.1 +androidx.profileinstaller:profileinstaller:1.4.0 +androidx.savedstate:savedstate-android:1.3.3 +androidx.savedstate:savedstate-compose-android:1.3.3 +androidx.savedstate:savedstate-compose:1.3.3 +androidx.savedstate:savedstate-ktx:1.3.3 +androidx.savedstate:savedstate:1.3.3 androidx.startup:startup-runtime:1.1.1 -androidx.tracing:tracing:1.0.0 -androidx.transition:transition:1.5.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 com.google.guava:listenablefuture:1.0 com.squareup.curtains:curtains:1.2.2 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 com.squareup.radiography:radiography:2.4.1 -org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4 -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4 -org.jetbrains.compose.annotation-internal:annotation:1.7.3 -org.jetbrains.compose.collection-internal:collection:1.7.3 -org.jetbrains.compose.runtime:runtime-saveable:1.7.3 -org.jetbrains.compose.runtime:runtime:1.7.3 -org.jetbrains.compose.ui:ui-geometry:1.7.3 -org.jetbrains.compose.ui:ui-graphics:1.7.3 -org.jetbrains.compose.ui:ui-text:1.7.3 -org.jetbrains.compose.ui:ui-unit:1.7.3 -org.jetbrains.compose.ui:ui-util:1.7.3 -org.jetbrains.compose.ui:ui:1.7.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.annotation-internal:annotation:1.10.3 +org.jetbrains.compose.collection-internal:collection:1.10.3 +org.jetbrains.compose.runtime:runtime-saveable:1.10.3 +org.jetbrains.compose.runtime:runtime:1.10.3 +org.jetbrains.compose.ui:ui-geometry:1.10.3 +org.jetbrains.compose.ui:ui-graphics:1.10.3 +org.jetbrains.compose.ui:ui-text:1.10.3 +org.jetbrains.compose.ui:ui-unit:1.10.3 +org.jetbrains.compose.ui:ui-util:1.10.3 +org.jetbrains.compose.ui:ui:1.10.3 org.jetbrains.kotlin:kotlin-bom:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.20 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.20 org.jetbrains.kotlin:kotlin-stdlib:2.3.20 -org.jetbrains.kotlinx:atomicfu-jvm:0.23.2 -org.jetbrains.kotlinx:atomicfu:0.23.2 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 From 76b80f135018823d91322f4ceb2bd6c5c2e41265 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 18:07:10 -0700 Subject: [PATCH 07/13] Remove unused ComposeWorkflowInterceptor This internal interface had no implementations, no callers, and its own KDoc referred to symbols that don't exist on this branch (LocalWorkflowComposableRuntimeConfig, com.squareup.workflow1.compose .renderChild). Looks like an early sketch of a Compose-native interceptor shape that was abandoned in favor of reusing the existing WorkflowInterceptor (plumbed through WorkflowComposableRuntimeConfig and consumed in ComposeRenderContext). --- .../compose/ComposeWorkflowInterceptor.kt | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt deleted file mode 100644 index 66c35adc44..0000000000 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowInterceptor.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.squareup.workflow1.internal.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.currentCompositeKeyHash -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.remember -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowExperimentalApi -import com.squareup.workflow1.identifier - -/** Typealias for the signature of [renderChild]. */ -internal typealias ComposableRenderChildFunction = @Composable ( - childWorkflow: Workflow, - props: PropsT, - onOutput: ((OutputT) -> Unit)?, -) -> RenderingT - -/** - * Intercepts calls to [com.squareup.workflow1.compose.renderChild]. - * See [ComposeWorkflowInterceptor.renderWorkflow] for more info. - * Install an interceptor by providing a [WorkflowComposableRuntimeConfig] via - * [LocalWorkflowComposableRuntimeConfig]. - * - * Note that this interface is [Stable] and so implementations must comply with the contract of that - * annotation. - */ -@Stable -internal interface ComposeWorkflowInterceptor { - - /** - * Called every time [com.squareup.workflow1.compose.renderChild] renders a workflow. - * - * Every "instance" of this method in composition is guaranteed to only ever be called with - * compatible instances of [childWorkflow]. That is, the current and previous values will have the - * same [identifier]. This means implementations do not need to worry about - * [keying off][androidx.compose.runtime.key] the workflow identifier themselves. - * - * Implementations of this method must follow some rules: - * - They MUST NOT call [proceed] more than once. - * - They MAY choose to not call [proceed] at all. - * - They MUST always call [proceed] from the same "position" in composition. Since Compose - * memoizes state positionally, moving the call around will reset the state for the entire - * workflow subtree. If you must move the call around, first try to refactor your code to avoid - * that. If you _really must_ move the call around, use [movableContentOf] but beware this has - * extra cost. - */ - @Composable - fun renderWorkflow( - childWorkflow: Workflow, - props: PropsT, - onOutput: ((OutputT) -> Unit)?, - proceed: ComposableRenderChildFunction - ): RenderingT -} From c925b5d8c499a64e1679a0839f3263661b3b9cd2 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 5 May 2026 18:25:38 -0700 Subject: [PATCH 08/13] Add unit tests for new compose runtime components Adds 51 unit tests across the compose-runtime helper types that aren't exercised end-to-end by the existing RenderWorkflowInTest/etc. coverage: PeekableMutableStateTest (10) - read/write, identity vs equals on the invalidator path, snapshot isolation. TrapdoorTest ( 7) - open(block & function form), inMovableGroup return values, runIfValueChanged change-detection semantics. RememberComposableTest ( 8) - skip/run paths for both the skippable and skippable+restartable wrappers, plus a regression test that the cache no longer calls .equals() on the cached value. WithCompositionLocalsTest ( 7) - default value, returned value, static locals, nesting, multi-local provision. WorkflowSnapshotSaverTest ( 4) - snapshotState/initialState delegation, null-snapshot pass- through, scope plumbing, round-trip. SynchronizedMoleculeTest ( 6) - first compose returns its value, per-call content updates, between-call state pickup, close() teardown, exception propagation. SerializableSaveableStateRegistryTest (jvm, 9) - canBeSaved predicate (Serializable / Function / non- Serializable / lists-of-lambdas), restoredValues / consumeRestored, registerProvider+unregister, and a doc-anchor test pinning the fact that toSnapshot() currently writes empty bytes (since the writeTo extension is commented out in the prod source). Also adds a small TestComposition helper that hosts a SynchronizedMolecule and applies snapshot writes immediately, so individual tests don't have to coordinate with a real frame clock. --- .../compose/PeekableMutableStateTest.kt | 107 +++++++++++ .../compose/RememberComposableTest.kt | 170 ++++++++++++++++++ .../internal/compose/TestComposition.kt | 51 ++++++ .../internal/compose/TrapdoorTest.kt | 120 +++++++++++++ .../compose/WithCompositionLocalsTest.kt | 107 +++++++++++ .../compose/WorkflowSnapshotSaverTest.kt | 124 +++++++++++++ .../runtime/SynchronizedMoleculeTest.kt | 93 ++++++++++ .../SerializableSaveableStateRegistryTest.kt | 101 +++++++++++ 8 files changed, 873 insertions(+) create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TestComposition.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TrapdoorTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WithCompositionLocalsTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaverTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMoleculeTest.kt create mode 100644 workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt new file mode 100644 index 0000000000..4ea8ad5c59 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt @@ -0,0 +1,107 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.snapshots.Snapshot +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame + +internal class PeekableMutableStateTest { + + @BeforeTest fun setUp() { + // PeekableMutableState is a snapshot StateObject; writing to it triggers the global write + // observer registered by GlobalSnapshotManager. Without this flag, the observer tries to + // launch on Dispatchers.Main, which isn't installed in plain JVM tests. + enableImmediateApplyForTests() + } + + @Test fun returns_initial_value() { + val state = PeekableMutableState("init") + assertEquals("init", state.value) + } + + @Test fun assignment_updates_value() { + val state = PeekableMutableState("init") + state.value = "next" + assertEquals("next", state.value) + } + + @Test fun component1_reads_and_component2_writes() { + val state = PeekableMutableState(1) + val (read, write) = state + assertEquals(1, read) + write(42) + assertEquals(42, state.value) + } + + @Test fun setWithInvalidator_invokes_invalidator_on_change() { + val state = PeekableMutableState("init") + var calls = 0 + state.setWithInvalidator("changed") { calls++ } + assertEquals(1, calls) + assertEquals("changed", state.value) + } + + @Test fun setWithInvalidator_skips_invalidator_when_value_equals_existing() { + val state = PeekableMutableState("same") + var calls = 0 + state.setWithInvalidator("same") { calls++ } + assertEquals(0, calls) + assertEquals("same", state.value) + } + + @Test fun setWithInvalidator_accepts_null_invalidator() { + val state = PeekableMutableState(0) + state.setWithInvalidator(7, invalidator = null) + assertEquals(7, state.value) + } + + @Test fun assignment_does_not_invoke_an_invalidator() { + // The plain `value =` setter is documented to delegate to setWithInvalidator with null. This + // test pins that contract: writes via the setter should not trigger any side effect beyond + // updating the value (i.e., the no-op null invalidator path). + val state = PeekableMutableState("init") + state.value = "next" + // No assertion beyond no-throw — the contract is "no invalidator to invoke". + assertEquals("next", state.value) + } + + @Test fun snapshot_isolation_writes_are_invisible_until_apply() { + val state = PeekableMutableState("init") + val snapshot = Snapshot.takeMutableSnapshot() + try { + snapshot.enter { state.value = "in-snapshot" } + // Outside the snapshot, the global view still sees the original value. + assertEquals("init", state.value) + snapshot.apply().check() + assertEquals("in-snapshot", state.value) + } finally { + snapshot.dispose() + } + } + + @Test fun snapshot_isolation_reads_inside_snapshot_see_writes_inside_snapshot() { + val state = PeekableMutableState("init") + val snapshot = Snapshot.takeMutableSnapshot() + try { + snapshot.enter { + state.value = "in-snapshot" + assertEquals("in-snapshot", state.value) + } + } finally { + snapshot.dispose() + } + } + + @Test fun setWithInvalidator_uses_equals_not_identity() { + // Verifies that the equality check uses .equals(), not identity. Two distinct list instances + // with the same content should compare equal so the invalidator is not invoked. + val original = listOf(1, 2, 3) + val newButEqual = listOf(1, 2, 3) + assertNotSame(original, newButEqual) + val state = PeekableMutableState(original) + var calls = 0 + state.setWithInvalidator(newButEqual) { calls++ } + assertEquals(0, calls) + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt new file mode 100644 index 0000000000..90338bf07f --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt @@ -0,0 +1,170 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +internal class RememberComposableTest { + + @Test fun rememberSkippableAndRestartable_runs_producer_on_first_compose() = runTest { + val test = TestComposition(backgroundScope) + try { + var calls = 0 + val producer: @Composable () -> Int = { calls++; 42 } + val result = test.recompose { + rememberSkippableAndRestartableComposable(key1 = "a", key2 = "b", producer = producer) + } + assertEquals(42, result) + assertEquals(1, calls) + } finally { + test.close() + } + } + + @Test fun rememberSkippableAndRestartable_skips_producer_when_keys_unchanged() = runTest { + val test = TestComposition(backgroundScope) + try { + val trigger = mutableStateOf(0) + var calls = 0 + val producer: @Composable () -> String = { calls++; "rendering" } + val content: @Composable () -> String = { + // Read trigger so the caller invalidates when it changes. + trigger.value + rememberSkippableAndRestartableComposable("k1", "k2", producer) + } + test.recompose(content) + trigger.value = 1 + val r2 = test.recompose(content) + assertEquals(1, calls, "Producer should be skipped when keys are unchanged") + assertEquals("rendering", r2) + } finally { + test.close() + } + } + + @Test fun rememberSkippableAndRestartable_runs_producer_when_key1_changes() = runTest { + val test = TestComposition(backgroundScope) + try { + var calls = 0 + val producer: @Composable () -> Int = { calls++; calls } + val key1Box = mutableStateOf("a") + val content: @Composable () -> Int = { + rememberSkippableAndRestartableComposable(key1Box.value, "fixed", producer) + } + test.recompose(content) + assertEquals(1, calls) + key1Box.value = "b" + test.recompose(content) + assertEquals(2, calls) + } finally { + test.close() + } + } + + @Test fun rememberSkippableAndRestartable_runs_producer_when_key2_changes() = runTest { + val test = TestComposition(backgroundScope) + try { + var calls = 0 + val producer: @Composable () -> Int = { calls++; calls } + val key2Box = mutableStateOf(0) + val content: @Composable () -> Int = { + rememberSkippableAndRestartableComposable("fixed", key2Box.value, producer) + } + test.recompose(content) + assertEquals(1, calls) + key2Box.value = 1 + test.recompose(content) + assertEquals(2, calls) + } finally { + test.close() + } + } + + @Test fun rememberSkippableAndRestartable_caches_with_identity_not_equals() = runTest { + // Key invariant: workflow renderings are allowed to have throwing equals/hashCode (this is + // tested at the runtime level by `exceptions_from_renderings_equals_methods_do_not_fail_runtime`). + // The cache check in rememberSkippable*Composable must therefore use identity, not equals. + class ThrowingEquals(val v: Int) { + override fun equals(other: Any?): Boolean = error("equals called!") + override fun hashCode(): Int = error("hashCode called!") + } + + val test = TestComposition(backgroundScope) + try { + val trigger = mutableStateOf(0) + var producerCalls = 0 + val producer: @Composable () -> ThrowingEquals = { + producerCalls++ + ThrowingEquals(producerCalls) + } + val content: @Composable () -> ThrowingEquals = { + trigger.value + rememberSkippableAndRestartableComposable("k1", "k2", producer) + } + val first = test.recompose(content) + trigger.value = 1 + val second = test.recompose(content) + // Producer was skipped, returning the same instance from the cache. Crucially: equals was + // never invoked, so the throwing equals didn't fire. + assertSame(first, second) + } finally { + test.close() + } + } + + @Test fun rememberSkippableComposable_runs_producer_on_first_compose() = runTest { + val test = TestComposition(backgroundScope) + try { + var calls = 0 + val producer: @Composable () -> Int = { calls++; 7 } + val result = test.recompose { + rememberSkippableComposable(key1 = "a", key2 = "b", producer = producer) + } + assertEquals(7, result) + assertEquals(1, calls) + } finally { + test.close() + } + } + + @Test fun rememberSkippableComposable_runs_producer_when_keys_change() = runTest { + val test = TestComposition(backgroundScope) + try { + var calls = 0 + val producer: @Composable () -> Int = { calls++; calls } + val key1Box = mutableStateOf("a") + val content: @Composable () -> Int = { + rememberSkippableComposable(key1Box.value, "fixed", producer) + } + test.recompose(content) + assertEquals(1, calls) + key1Box.value = "b" + test.recompose(content) + assertEquals(2, calls) + } finally { + test.close() + } + } + + @Test fun rememberSkippableComposable_skips_producer_when_keys_unchanged() = runTest { + val test = TestComposition(backgroundScope) + try { + val trigger = mutableStateOf(0) + var calls = 0 + val producer: @Composable () -> Int = { calls++; calls } + val content: @Composable () -> Int = { + trigger.value + rememberSkippableComposable("k1", "k2", producer) + } + test.recompose(content) + trigger.value = 1 + test.recompose(content) + assertEquals(1, calls) + } finally { + test.close() + } + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TestComposition.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TestComposition.kt new file mode 100644 index 0000000000..8732b6eec5 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TestComposition.kt @@ -0,0 +1,51 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.internal.compose.runtime.SynchronizedMolecule +import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule +import com.squareup.workflow1.internal.compose.runtime.setGlobalSnapshotManagerSendApplyImmediately +import kotlinx.coroutines.CoroutineScope + +/** + * Test harness that hosts a [SynchronizedMolecule] and applies snapshot writes immediately so + * tests don't have to coordinate with a real frame clock dispatcher. Use [recompose] to run + * a composable; if any state read inside the composable was changed since the previous call, + * Compose will recompose the affected scopes before returning. + * + * The global "send apply immediately" flag is enabled (via [enableImmediateApplyForTests]) but + * never turned off, because [GlobalSnapshotManager]'s registered global write observer will + * otherwise try to dispatch to `Dispatchers.Main`, which isn't installed in plain JVM unit tests. + * + * Tests should call [close] in a `finally` (or via a deferred cleanup) to dispose the underlying + * recomposer. + */ +internal class TestComposition(scope: CoroutineScope) { + private var recomposeRequests: Int = 0 + private val molecule: SynchronizedMolecule = run { + enableImmediateApplyForTests() + scope.launchSynchronizedMolecule(onNeedsRecomposition = { recomposeRequests++ }) + } + + /** Number of times the molecule has signaled that recomposition is needed. */ + val recomposeRequestCount: Int get() = recomposeRequests + + /** Mirrors [SynchronizedMolecule.needsRecomposition]. */ + val needsRecomposition: Boolean get() = molecule.needsRecomposition + + fun recompose(content: @Composable () -> R): R = molecule.recomposeWithContent(content) + + fun close() { + molecule.close() + } +} + +/** + * Ensures [GlobalSnapshotManager]'s global write observer dispatches its + * `Snapshot.sendApplyNotifications()` call synchronously instead of trying to use + * `Dispatchers.Main`. Once enabled, it stays enabled for the rest of the test JVM. + * + * Idempotent — safe to call from any test, including ones that don't use [TestComposition]. + */ +internal fun enableImmediateApplyForTests() { + setGlobalSnapshotManagerSendApplyImmediately(true) +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TrapdoorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TrapdoorTest.kt new file mode 100644 index 0000000000..b9f8dcdea6 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/TrapdoorTest.kt @@ -0,0 +1,120 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import com.squareup.workflow1.internal.compose.Trapdoor.Companion.runIfValueChanged +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +internal class TrapdoorTest { + + @Test fun open_block_form_passes_a_trapdoor_into_block() = runTest { + val test = TestComposition(backgroundScope) + try { + var captured: Trapdoor? = null + test.recompose { + Trapdoor.open { door -> captured = door } + } + assertNotNull(captured) + } finally { + test.close() + } + } + + @Test fun open_function_form_returns_a_trapdoor() = runTest { + val test = TestComposition(backgroundScope) + try { + val door: Trapdoor = test.recompose { Trapdoor.open() } + assertNotNull(door) + } finally { + test.close() + } + } + + @Test fun inMovableGroup_returns_value_from_content() = runTest { + val test = TestComposition(backgroundScope) + try { + val result = test.recompose { + Trapdoor.open { door -> + door.inMovableGroup(key = 1, dataKey = "k") { 42 } + } + } + assertEquals(42, result) + } finally { + test.close() + } + } + + @Test fun inMovableGroup_with_two_data_keys_returns_value_from_content() = runTest { + val test = TestComposition(backgroundScope) + try { + val result = test.recompose { + Trapdoor.open { door -> + door.inMovableGroup(key = 1, dataKey1 = "a", dataKey2 = "b") { "ok" } + } + } + assertEquals("ok", result) + } finally { + test.close() + } + } + + @Test fun runIfValueChanged_does_not_call_on_first_compose() = runTest { + val test = TestComposition(backgroundScope) + try { + val state = mutableStateOf("a") + val seen = mutableListOf() + test.recompose { + runIfValueChanged(state.value) { old -> seen += old } + } + assertContentEquals(emptyList(), seen) + } finally { + test.close() + } + } + + @Test fun runIfValueChanged_calls_with_previous_value_when_value_changes() = runTest { + val test = TestComposition(backgroundScope) + try { + val state = mutableStateOf("a") + val seen = mutableListOf() + val content: @Composable () -> Unit = { + runIfValueChanged(state.value) { old -> seen += old } + } + test.recompose(content) + state.value = "b" + test.recompose(content) + assertContentEquals(listOf("a"), seen) + + state.value = "c" + test.recompose(content) + assertContentEquals(listOf("a", "b"), seen) + } finally { + test.close() + } + } + + @Test fun runIfValueChanged_does_not_call_when_value_unchanged_across_recomposition() = runTest { + val test = TestComposition(backgroundScope) + try { + val trigger = mutableStateOf(0) + val seen = mutableListOf() + val content: @Composable () -> Unit = { + // Read trigger so the composable gets invalidated each time it changes. + trigger.value + runIfValueChanged("constant") { old -> seen += old } + } + test.recompose(content) + trigger.value = 1 + test.recompose(content) + trigger.value = 2 + test.recompose(content) + assertContentEquals(emptyList(), seen) + } finally { + test.close() + } + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WithCompositionLocalsTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WithCompositionLocalsTest.kt new file mode 100644 index 0000000000..d96afd352f --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WithCompositionLocalsTest.kt @@ -0,0 +1,107 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class WithCompositionLocalsTest { + + @Test fun reads_provided_value_inside_content() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = compositionLocalOf { "default" } + val result = test.recompose { + withCompositionLocals(Local provides "provided") { Local.current } + } + assertEquals("provided", result) + } finally { + test.close() + } + } + + @Test fun returns_value_from_content_lambda() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = compositionLocalOf { 0 } + val result = test.recompose { + withCompositionLocals(Local provides 7) { Local.current * 2 } + } + assertEquals(14, result) + } finally { + test.close() + } + } + + @Test fun reads_default_when_no_provider_present() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = compositionLocalOf { "default" } + val result = test.recompose { Local.current } + assertEquals("default", result) + } finally { + test.close() + } + } + + @Test fun supports_static_composition_locals() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = staticCompositionLocalOf { "default" } + val result = test.recompose { + withCompositionLocals(Local provides "static") { Local.current } + } + assertEquals("static", result) + } finally { + test.close() + } + } + + @Test fun nested_calls_resolve_to_innermost_value() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = compositionLocalOf { "default" } + val result = test.recompose { + withCompositionLocals(Local provides "outer") { + withCompositionLocals(Local provides "inner") { Local.current } + } + } + assertEquals("inner", result) + } finally { + test.close() + } + } + + @Test fun outer_value_is_restored_after_inner_returns() = runTest { + val test = TestComposition(backgroundScope) + try { + val Local = compositionLocalOf { "default" } + val result = test.recompose { + withCompositionLocals(Local provides "outer") { + val inner = withCompositionLocals(Local provides "inner") { Local.current } + inner + ":" + Local.current + } + } + assertEquals("inner:outer", result) + } finally { + test.close() + } + } + + @Test fun multiple_locals_provided_at_once() = runTest { + val test = TestComposition(backgroundScope) + try { + val A = compositionLocalOf { "A0" } + val B = compositionLocalOf { "B0" } + val result = test.recompose { + withCompositionLocals(A provides "A1", B provides "B1") { + A.current + "-" + B.current + } + } + assertEquals("A1-B1", result) + } finally { + test.close() + } + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaverTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaverTest.kt new file mode 100644 index 0000000000..fa4620302b --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotSaverTest.kt @@ -0,0 +1,124 @@ +@file:OptIn(WorkflowExperimentalApi::class) + +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.saveable.SaverScope +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.parse +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.writeUtf8WithLength +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +internal class WorkflowSnapshotSaverTest { + + /** A test stub that records calls to its lifecycle methods. */ + private class RecordingWorkflow : StatefulWorkflow() { + val initialStateCalls = mutableListOf>() + val initialStateScopes = mutableListOf() + val snapshotStateCalls = mutableListOf() + + override fun initialState(props: String, snapshot: Snapshot?): String { + initialStateCalls += props to snapshot + // Read the bytes back to mimic real workflows that decode their state. + val restored = snapshot?.bytes?.parse { it.readUtf8WithLength() } + return restored ?: "init($props)" + } + + override fun initialState( + props: String, + snapshot: Snapshot?, + workflowScope: CoroutineScope + ): String { + initialStateScopes += workflowScope + return super.initialState(props, snapshot, workflowScope) + } + + override fun render( + renderProps: String, + renderState: String, + context: RenderContext + ) = Unit + + override fun snapshotState(state: String): Snapshot? { + snapshotStateCalls += state + return Snapshot.write { it.writeUtf8WithLength(state) } + } + } + + private val saverScope = SaverScope { true } + + @Test fun save_calls_workflow_snapshotState_with_value() { + val workflow = RecordingWorkflow() + val saver = WorkflowSnapshotSaver( + initialProps = "props", + statefulWorkflow = workflow, + workflowTracer = null, + workflowScope = TestScope(), + ) + val saved = with(saverScope) { with(saver) { save("the-state") } } + assertEquals(listOf("the-state"), workflow.snapshotStateCalls) + val decoded = saved!!.bytes.parse { it.readUtf8WithLength() } + assertEquals("the-state", decoded) + } + + @Test fun save_returns_null_when_workflow_returns_null() { + val workflow = object : StatefulWorkflow() { + override fun initialState(props: Unit, snapshot: Snapshot?) = "" + override fun render( + renderProps: Unit, + renderState: String, + context: RenderContext + ) = Unit + + override fun snapshotState(state: String): Snapshot? = null + } + val saver = WorkflowSnapshotSaver( + initialProps = Unit, + statefulWorkflow = workflow, + workflowTracer = null, + workflowScope = TestScope(), + ) + val saved = with(saverScope) { with(saver) { save("") } } + assertNull(saved) + } + + @Test fun restore_invokes_initialState_with_props_and_snapshot_and_scope() { + val workflow = RecordingWorkflow() + val scope = TestScope() + val saver = WorkflowSnapshotSaver( + initialProps = "the-props", + statefulWorkflow = workflow, + workflowTracer = null, + workflowScope = scope, + ) + val snapshot = Snapshot.write { it.writeUtf8WithLength("restored-state") } + val restored = saver.restore(snapshot) + + assertEquals("restored-state", restored) + val expectedCalls: List> = listOf("the-props" to snapshot) + assertEquals(expectedCalls, workflow.initialStateCalls) + // The 3-arg overload that takes workflowScope is the one called; verify our scope was passed. + assertEquals(1, workflow.initialStateScopes.size) + assertSame(scope, workflow.initialStateScopes.single()) + } + + @Test fun save_then_restore_round_trips_state() { + val workflow = RecordingWorkflow() + val saver = WorkflowSnapshotSaver( + initialProps = "p", + statefulWorkflow = workflow, + workflowTracer = null, + workflowScope = TestScope(), + ) + val saved = with(saverScope) { with(saver) { save("hello") } } + val restored = saver.restore(saved!!) + assertEquals("hello", restored) + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMoleculeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMoleculeTest.kt new file mode 100644 index 0000000000..329a606e06 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMoleculeTest.kt @@ -0,0 +1,93 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.squareup.workflow1.internal.compose.enableImmediateApplyForTests +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class SynchronizedMoleculeTest { + + @BeforeTest fun setUp() { + enableImmediateApplyForTests() + } + + @Test fun first_recompose_runs_content_and_returns_its_value() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + try { + val result = molecule.recomposeWithContent { 42 } + assertEquals(42, result) + } finally { + molecule.close() + } + } + + @Test fun recomposeWithContent_returns_latest_value_for_each_call() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + try { + // Each call gets a fresh content lambda. The molecule's internal `content` field is + // backed by mutableStateOf, so each new lambda triggers a recomposition. + assertEquals(1, molecule.recomposeWithContent { 1 }) + assertEquals(2, molecule.recomposeWithContent { 2 }) + assertEquals(3, molecule.recomposeWithContent { 3 }) + } finally { + molecule.close() + } + } + + @Test fun needsRecomposition_is_false_when_nothing_changed() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + try { + molecule.recomposeWithContent { "noop" } + assertFalse(molecule.needsRecomposition) + } finally { + molecule.close() + } + } + + @Test fun second_recompose_picks_up_state_changes_made_between_calls() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + try { + var state by mutableStateOf("first") + val content: @Composable () -> String = { state } + assertEquals("first", molecule.recomposeWithContent(content)) + state = "second" + assertEquals("second", molecule.recomposeWithContent(content)) + } finally { + molecule.close() + } + } + + @Test fun close_makes_needsRecomposition_return_false() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + molecule.recomposeWithContent { Unit } + molecule.close() + assertFalse(molecule.needsRecomposition) + } + + @Test fun composition_throwing_propagates_from_recomposeWithContent() = runTest { + val molecule = backgroundScope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + try { + molecule.recomposeWithContent { Unit } + val state = mutableStateOf(false) + val content: @Composable () -> Int = { + if (state.value) error("oops") + 0 + } + molecule.recomposeWithContent(content) + state.value = true + assertFailsWith { + molecule.recomposeWithContent(content) + } + } finally { + molecule.close() + } + } +} diff --git a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt new file mode 100644 index 0000000000..7b1d10da97 --- /dev/null +++ b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt @@ -0,0 +1,101 @@ +package com.squareup.workflow1.internal.compose.runtime + +import com.squareup.workflow1.Snapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * NOTE: at the time these tests were written, [SerializableSaveableStateRegistry.toSnapshot] is + * effectively a no-op (its `writeTo` extension is commented out in the production source). That + * makes the round-trip + * `registry.toSnapshot() -> SerializableSaveableStateRegistry(snapshot)` non-functional: the + * resulting Snapshot has empty bytes, and reconstructing from it throws while trying to read the + * length prefix. The tests below cover everything else: the canBeSaved predicate, the + * restoredValues constructor, the registerProvider/consumeRestored paths, and explicitly pin the + * current broken round-trip behavior so it'll need to be updated when the impl is finished. + */ +internal class SerializableSaveableStateRegistryTest { + + @Test fun canBeSaved_accepts_serializable_value() { + val registry = SerializableSaveableStateRegistry(restoredValues = null) + assertTrue(registry.canBeSaved("a string")) + assertTrue(registry.canBeSaved(42)) + assertTrue(registry.canBeSaved(arrayListOf(1, 2, 3))) + } + + @Test fun canBeSaved_rejects_non_serializable_value() { + class NotSerializable + val registry = SerializableSaveableStateRegistry(restoredValues = null) + assertFalse(registry.canBeSaved(NotSerializable())) + } + + @Test fun canBeSaved_rejects_function_even_if_serializable() { + // Kotlin lambdas implement Serializable, but actually serializing them tends to fail at + // runtime, so the registry filters them out explicitly. + val lambda: () -> Int = { 42 } + val registry = SerializableSaveableStateRegistry(restoredValues = null) + assertFalse(registry.canBeSaved(lambda)) + } + + @Test fun consumeRestored_returns_null_when_no_value_was_provided() { + val registry = SerializableSaveableStateRegistry(restoredValues = null) + assertEquals(null, registry.consumeRestored("absent")) + } + + @Test fun consumeRestored_returns_first_provided_value_for_a_key() { + val restored = mapOf("k" to listOf("v1", "v2")) + val registry = SerializableSaveableStateRegistry(restoredValues = restored) + assertEquals("v1", registry.consumeRestored("k")) + // Subsequent consume picks up the next value, then null when exhausted. + assertEquals("v2", registry.consumeRestored("k")) + assertEquals(null, registry.consumeRestored("k")) + } + + @Test fun registered_provider_value_appears_in_performSave() { + val registry = SerializableSaveableStateRegistry(restoredValues = null) + val entry = registry.registerProvider(key = "k") { "value-from-provider" } + val saved = registry.performSave() + assertEquals(listOf("value-from-provider"), saved["k"]) + entry.unregister() + val savedAfterUnregister = registry.performSave() + assertFalse(savedAfterUnregister.containsKey("k")) + } + + @Test fun toSnapshot_returns_a_snapshot_object() { + // The snapshot is currently empty since writeTo is not implemented, but we still verify the + // method does not throw and returns a non-null Snapshot. This pins the invariant that + // toSnapshot() is at least call-safe with no registered providers. + val registry = SerializableSaveableStateRegistry(restoredValues = null) + val snapshot = registry.toSnapshot() + // The current broken implementation produces empty bytes. When writeTo is implemented this + // assertion will need to be updated. + assertEquals(0, snapshot.bytes.size) + } + + @Test fun reconstruct_from_empty_snapshot_throws_eof() { + // Documents the current broken round-trip. Once writeTo is implemented and writes a 0-length + // map for the empty case (or some empty marker), this test should be inverted to verify a + // graceful round-trip. + val emptySnapshot = Snapshot.write { /* no bytes */ } + assertFails { + // Constructing from an empty snapshot tries to read a length prefix that isn't there. + SerializableSaveableStateRegistry(emptySnapshot).also { + // Just to be safe, exercise it. + it.canBeSaved("anything") + } + } + } + + @Test fun canBeSaved_rejects_nested_function_in_collection_is_callers_responsibility() { + // SaveableStateRegistry's canBeSaved is per-value, not recursive. A list containing a lambda + // will pass canBeSaved (the list itself is Serializable), even though serializing it would + // fail. Document the contract here so anyone changing the predicate sees this expectation. + val listWithLambda: List = listOf({ 42 }) + val registry = SerializableSaveableStateRegistry(restoredValues = null) + assertTrue(registry.canBeSaved(listWithLambda)) + } +} From 4712876b87baf60130da34257271ef3455dc39f1 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Wed, 6 May 2026 08:56:10 -0700 Subject: [PATCH 09/13] Remove unused SerializableSaveableStateRegistry The class was internal with no production callers, and its toSnapshot writeTo body was a commented-out no-op. Only its own test referenced it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SerializableSaveableStateRegistry.kt | 72 ------------- .../SerializableSaveableStateRegistryTest.kt | 101 ------------------ 2 files changed, 173 deletions(-) delete mode 100644 workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt delete mode 100644 workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt deleted file mode 100644 index d5df1d38c2..0000000000 --- a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.squareup.workflow1.internal.compose.runtime - -import androidx.compose.runtime.saveable.SaveableStateRegistry -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.parse -import com.squareup.workflow1.readUtf8WithLength -import com.squareup.workflow1.writeUtf8WithLength -import okio.BufferedSink -import okio.ByteString -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.io.Serializable - -/** - * A [SaveableStateRegistry] that can save and restore anything that is [Serializable]. - */ -internal class SerializableSaveableStateRegistry private constructor( - saveableStateRegistry: SaveableStateRegistry -) : SaveableStateRegistry by saveableStateRegistry { - constructor(restoredValues: Map>?) : this( - SaveableStateRegistry(restoredValues, ::canBeSavedAsSerializable) - ) - - constructor(snapshot: Snapshot) : this(snapshot.bytes.toMap()) - - fun toSnapshot(): Snapshot = Snapshot.write { sink -> - performSave().writeTo(sink) - } -} - -/** - * Checks that [value] can be stored as a [Serializable]. - */ -private fun canBeSavedAsSerializable(value: Any): Boolean { - if (value !is Serializable) return false - - // lambdas in Kotlin implement Serializable, but will crash if you really try to save them. - // we check for both Function and Serializable (see kotlin.jvm.internal.Lambda) to support - // custom user defined classes implementing Function interface. - if (value is Function<*>) return false - - return true -} - -private fun ByteString.toMap(): Map>? { - return parse { source -> - val size = source.readInt() - if (size == 0) return null - - val inputStream = ObjectInputStream(source.inputStream()) - buildMap(capacity = size) { - repeat(size) { - val key = source.readUtf8WithLength() - - @Suppress("UNCHECKED_CAST") - val arrayList = inputStream.readObject() as ArrayList - put(key, arrayList) - } - } - } -} - -private fun Map>.writeTo(sink: BufferedSink) { - // sink.writeInt(values.size) - // val outputStream = ObjectOutputStream(sink.outputStream()) - // values.forEach { (key, list) -> - // val arrayList = if (list is ArrayList) list else ArrayList(list) - // sink.writeUtf8WithLength(key) - // outputStream.writeObject(arrayList) - // outputStream.flush() - // } -} diff --git a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt deleted file mode 100644 index 7b1d10da97..0000000000 --- a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistryTest.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.squareup.workflow1.internal.compose.runtime - -import com.squareup.workflow1.Snapshot -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFails -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.fail - -/** - * NOTE: at the time these tests were written, [SerializableSaveableStateRegistry.toSnapshot] is - * effectively a no-op (its `writeTo` extension is commented out in the production source). That - * makes the round-trip - * `registry.toSnapshot() -> SerializableSaveableStateRegistry(snapshot)` non-functional: the - * resulting Snapshot has empty bytes, and reconstructing from it throws while trying to read the - * length prefix. The tests below cover everything else: the canBeSaved predicate, the - * restoredValues constructor, the registerProvider/consumeRestored paths, and explicitly pin the - * current broken round-trip behavior so it'll need to be updated when the impl is finished. - */ -internal class SerializableSaveableStateRegistryTest { - - @Test fun canBeSaved_accepts_serializable_value() { - val registry = SerializableSaveableStateRegistry(restoredValues = null) - assertTrue(registry.canBeSaved("a string")) - assertTrue(registry.canBeSaved(42)) - assertTrue(registry.canBeSaved(arrayListOf(1, 2, 3))) - } - - @Test fun canBeSaved_rejects_non_serializable_value() { - class NotSerializable - val registry = SerializableSaveableStateRegistry(restoredValues = null) - assertFalse(registry.canBeSaved(NotSerializable())) - } - - @Test fun canBeSaved_rejects_function_even_if_serializable() { - // Kotlin lambdas implement Serializable, but actually serializing them tends to fail at - // runtime, so the registry filters them out explicitly. - val lambda: () -> Int = { 42 } - val registry = SerializableSaveableStateRegistry(restoredValues = null) - assertFalse(registry.canBeSaved(lambda)) - } - - @Test fun consumeRestored_returns_null_when_no_value_was_provided() { - val registry = SerializableSaveableStateRegistry(restoredValues = null) - assertEquals(null, registry.consumeRestored("absent")) - } - - @Test fun consumeRestored_returns_first_provided_value_for_a_key() { - val restored = mapOf("k" to listOf("v1", "v2")) - val registry = SerializableSaveableStateRegistry(restoredValues = restored) - assertEquals("v1", registry.consumeRestored("k")) - // Subsequent consume picks up the next value, then null when exhausted. - assertEquals("v2", registry.consumeRestored("k")) - assertEquals(null, registry.consumeRestored("k")) - } - - @Test fun registered_provider_value_appears_in_performSave() { - val registry = SerializableSaveableStateRegistry(restoredValues = null) - val entry = registry.registerProvider(key = "k") { "value-from-provider" } - val saved = registry.performSave() - assertEquals(listOf("value-from-provider"), saved["k"]) - entry.unregister() - val savedAfterUnregister = registry.performSave() - assertFalse(savedAfterUnregister.containsKey("k")) - } - - @Test fun toSnapshot_returns_a_snapshot_object() { - // The snapshot is currently empty since writeTo is not implemented, but we still verify the - // method does not throw and returns a non-null Snapshot. This pins the invariant that - // toSnapshot() is at least call-safe with no registered providers. - val registry = SerializableSaveableStateRegistry(restoredValues = null) - val snapshot = registry.toSnapshot() - // The current broken implementation produces empty bytes. When writeTo is implemented this - // assertion will need to be updated. - assertEquals(0, snapshot.bytes.size) - } - - @Test fun reconstruct_from_empty_snapshot_throws_eof() { - // Documents the current broken round-trip. Once writeTo is implemented and writes a 0-length - // map for the empty case (or some empty marker), this test should be inverted to verify a - // graceful round-trip. - val emptySnapshot = Snapshot.write { /* no bytes */ } - assertFails { - // Constructing from an empty snapshot tries to read a length prefix that isn't there. - SerializableSaveableStateRegistry(emptySnapshot).also { - // Just to be safe, exercise it. - it.canBeSaved("anything") - } - } - } - - @Test fun canBeSaved_rejects_nested_function_in_collection_is_callers_responsibility() { - // SaveableStateRegistry's canBeSaved is per-value, not recursive. A list containing a lambda - // will pass canBeSaved (the list itself is Serializable), even though serializing it would - // fail. Document the contract here so anyone changing the predicate sees this expectation. - val listWithLambda: List = listOf({ 42 }) - val registry = SerializableSaveableStateRegistry(restoredValues = null) - assertTrue(registry.canBeSaved(listWithLambda)) - } -} From fca83efdb38f2d1bc1214e9485ae2c467aa345cd Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Wed, 6 May 2026 09:17:47 -0700 Subject: [PATCH 10/13] Add documentation to main Compose workflow entry point. --- .../internal/compose/RenderWorkflowWithComposeRuntime.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt index 2cbd8fb285..7595c31030 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt @@ -40,6 +40,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import okio.ByteString +/** + * This is the entry point into the entire Compose-based workflow runtime. It owns the Compose + * runtime that manages the workflow tree. + */ @OptIn(ExperimentalComposeRuntimeApi::class) internal fun renderWorkflowWithComposeRuntimeIn( workflow: Workflow, From 56b40d6c4f91ff8f1eb9fa5e148cb9cf9811a4cd Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 7 May 2026 15:05:57 -0700 Subject: [PATCH 11/13] Tried improving skip/restart performance by getting rid of the lambda param. Actually made things worse. Might be because of all the extra vals to try to propagate the changed param. --- .../WorkflowRuntimeMicrobenchmark.kt | 8 +- .../com/squareup/workflow1/RuntimeConfig.kt | 11 +- .../internal/compose/RememberComposable.kt | 431 ------------------ .../internal/compose/RenderWorkflow.kt | 229 +++++++++- .../compose/RememberComposableTest.kt | 170 ------- 5 files changed, 228 insertions(+), 621 deletions(-) delete mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt delete mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt diff --git a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt index ce37d0e2d9..eb45ea4ad4 100644 --- a/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt +++ b/benchmarks/runtime-microbenchmark/src/androidTest/kotlin/com/squareup/benchmark/runtime/benchmark/WorkflowRuntimeMicrobenchmark.kt @@ -4,7 +4,6 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.tracing.Trace import app.cash.burst.Burst -import com.squareup.benchmark.runtime.benchmark.BenchmarkRuntimeOptions.NoOptimizations import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions import com.squareup.workflow1.Sink @@ -40,9 +39,10 @@ import kotlin.time.Duration.Companion.minutes enum class BenchmarkRuntimeOptions( val runtimeConfig: RuntimeConfig ) { - NoOptimizations(RuntimeOptions.NONE.runtimeConfig), + // NoOptimizations(RuntimeOptions.NONE.runtimeConfig), AllOptimizations(RuntimeOptions.ALL.runtimeConfig), - Compose(RuntimeOptions.COMPOSE_RUNTIME_ONLY.runtimeConfig), + ComposeNoSkip(RuntimeOptions.COMPOSE_RUNTIME_NON_SKIPPING.runtimeConfig), + ComposeSkipping(RuntimeOptions.COMPOSE_RUNTIME_SKIPPING.runtimeConfig), } enum class BenchmarkTreeShape( @@ -57,7 +57,7 @@ enum class BenchmarkTreeShape( @Burst class WorkflowRuntimeMicrobenchmark( private val treeShape: BenchmarkTreeShape = BenchmarkTreeShape.ShallowBushyTree, - private val runtime: BenchmarkRuntimeOptions = NoOptimizations, + private val runtime: BenchmarkRuntimeOptions = BenchmarkRuntimeOptions.AllOptimizations, ) { private companion object { diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt index cbed3c1202..19ccd44924 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -119,6 +119,9 @@ public enum class RuntimeConfigOptions { */ @WorkflowExperimentalRuntime COMPOSE_RUNTIME, + + @WorkflowExperimentalRuntime + COMPOSE_RUNTIME_SKIPPING, ; public companion object { @@ -481,7 +484,13 @@ public enum class RuntimeConfigOptions { ) ), - COMPOSE_RUNTIME_ONLY(setOf(RuntimeConfigOptions.COMPOSE_RUNTIME)), + COMPOSE_RUNTIME_NON_SKIPPING(setOf(RuntimeConfigOptions.COMPOSE_RUNTIME)), + COMPOSE_RUNTIME_SKIPPING( + setOf( + RuntimeConfigOptions.COMPOSE_RUNTIME, + RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING + ) + ), /** * Always contains all [RuntimeConfigOptions]. Other values in this enum may happen to contain diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt deleted file mode 100644 index 8ce087be8c..0000000000 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RememberComposable.kt +++ /dev/null @@ -1,431 +0,0 @@ -@file:OptIn(InternalComposeApi::class) - -package com.squareup.workflow1.internal.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composer -import androidx.compose.runtime.ExplicitGroupsComposable -import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.RecomposeScope -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.currentRecomposeScope -import androidx.compose.runtime.remember - -// From https://github.com/squareup/market/pull/11218/ - -/** - * Invokes [producer] as a restartable, skippable composable by caching its return value. - * - * The first time this composable is called it runs [producer] and remembers its return value in the - * same way as [remember]. - * - * When the caller is recomposed (calling this composable again), if the same [producer] instance is - * passed and [producer] hasn't been invalidated itself (i.e. due to a state change), then [producer] - * is skipped (i.e. state is kept in the composition but it is not recomposed) and the cached value is - * returned. If a different [producer] instance is passed, it's composed and its return value is cached - * before being returned. - * - * [producer] is _not_ treated as an implicit key: only the - * initial [producer] instance passed to this function will ever be called. The compose compiler will - * normally auto-remember lambdas passed to composables, but _only if they return `Unit`_. Since - * [producer] has a non-`Unit` return type, it will never be auto-remembered. So this function always - * remembers [producer] itself. If we treated [producer] as a key, it would always be recomposed unless - * the caller explicitly remembered it. To update the producer logic, store it in a [MutableState]: - * - * **Maintainer note: If we do this as a compiler plugin, we should also implement auto-memoizing - * lambdas for this case.** - * - * @see rememberSkippableAndRestartableComposable - */ -// We don't need the compiler to generate groups because the impl non-Composable function explicitly -// creates its own groups anyway. -@ExplicitGroupsComposable -@Composable -internal fun rememberSkippableComposable( - key1: Any?, - key2: Any?, - producer: @Composable () -> R -): R = rememberSkippableComposableImpl( - arg1 = key1, - arg2 = key2, - arg3 = Unit, - producer = producer, - composer = currentComposer, - changed = 0, -) - -// // We don't need the compiler to generate groups because the impl non-Composable function explicitly -// // creates its own groups anyway. -// @ExplicitGroupsComposable -// @Composable -// inline fun rememberSkippableComposable( -// key1: Any?, -// key2: Any?, -// producer: @Composable () -> R -// ): R { -// val composer = currentComposer -// val runProducer = rSKC_before( -// arg1 = key1, -// arg2 = key2, -// arg3 = Unit, -// composer = composer, -// ) -// -// val newValue = if (runProducer) { -// producer() -// } else { -// Composer.Empty -// } -// -// return rSKC_after(newValue, composer) -// } - -// // We don't need the compiler to generate groups because the impl non-Composable function explicitly -// // creates its own groups anyway. -// @ExplicitGroupsComposable -// @Composable -// inline fun rememberSkippableComposable( -// key1: Any?, -// key2: Any?, -// key3: Any?, -// producer: @Composable () -> R -// ): R { -// val composer = currentComposer -// val changed = 0 -// -// // Outer group has two "children": The restartable group that might be skipped if no keys have -// // changed, and the remembered return value. -// // Key chosen "randomly" by mashing on my keyboard. -// composer.startReplaceGroup(23975235) -// -// // region Recompose producer -// // Inner group is necessary to be able to skip calling producer. We need a nested group because we -// // only want to skip calling producer, we still need to do other slot table stuff later to -// // read the cache even if producer is skipped. -// // Key chosen "randomly" by mashing on my keyboard. -// composer.startReplaceGroup(-895983) -// -// // We don't consider producer in this change detection code since we don't want to treat it as a -// // key. -// var dirty = changed -// if ((changed and 0b110) == 0) { -// dirty = changed or (if (composer.changed(key1)) 0b100 else 0b010) -// } -// if ((changed and 0b110_000) == 0) { -// dirty = dirty or (if (composer.changed(key2)) 0b100_000 else 0b010_000) -// } -// if ((changed and 0b110_000_000) == 0) { -// dirty = dirty or (if (composer.changed(key3)) 0b100_000_000 else 0b010_000_000) -// } -// -// val newValue = if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { -// composer.skipToGroupEnd() -// Composer.Empty -// } else { -// producer() -// } -// -// composer.endReplaceGroup() -// // endregion -// -// // region Update cache -// // Cache the return value in case we skipped above. Composer APIs require always reading the value -// // first, and then calling updateRememberedValue the first time or optionally on subsequent -// // recompositions. -// val oldValue = composer.rememberedValue() -// -// @Suppress("SuspiciousEqualsCombination") -// val returnValue = if ( -// oldValue === Composer.Empty || -// (newValue !== Composer.Empty && newValue != oldValue) -// ) { -// // Update the cache. -// if (newValue === Composer.Empty) error("No new value was calculated.") -// composer.updateRememberedValue(newValue) -// newValue -// } else { -// // Producer was skipped (or returned the same value), return from the cache. -// oldValue -// } -// // endregion -// -// composer.endReplaceGroup() -// @Suppress("UNCHECKED_CAST") -// return returnValue as R -// } - -/** - * Invokes [producer] as a restartable, skippable composable by caching its return value. - * - * The first time this composable is called it runs [producer] and remembers its return value in the - * same way as [remember]. - * - * When the caller is recomposed (calling this composable again), if the same [producer] instance is - * passed and [producer] hasn't been invalidated itself (i.e. due to a state change), then [producer] - * is skipped (i.e. state is kept in the composition but it is not recomposed) and the cached value is - * returned. If a different [producer] instance is passed, it's composed and its return value is cached - * before being returned. - * - * When [producer] is invalidated independently (i.e. due to a state it read being changed), without - * the caller being invalidated, then just [producer] is recomposed at first and its return value is - * compared with the cached value: If it's different, and only if it's different, then the new value - * is cached and the caller is invalidated, which will cause the caller to be recomposed during the - * same composition phase of the same frame. When this happens, the same caching behavior applies: if - * the same instance of [producer] is passed in then it will be skipped and the cached value will be - * returned. - * - * [producer] is _not_ treated as an implicit key: only the - * initial [producer] instance passed to this function will ever be called. The compose compiler will - * normally auto-remember lambdas passed to composables, but _only if they return `Unit`_. Since - * [producer] has a non-`Unit` return type, it will never be auto-remembered. So this function always - * remembers [producer] itself. If we treated [producer] as a key, it would always be recomposed unless - * the caller explicitly remembered it. To update the producer logic, store it in a [MutableState]: - * - * **Maintainer note: If we do this as a compiler plugin, we should also implement auto-memoizing - * lambdas for this case.** - * - * @see rememberSkippableComposable - */ -// We don't need the compiler to generate groups because the impl non-Composable function has to -// explicitly create its own groups anyway. -@ExplicitGroupsComposable -@Composable -internal fun rememberSkippableAndRestartableComposable( - key1: Any?, - key2: Any?, - producer: @Composable () -> R -): R = rememberSkippableAndRestartableComposableImpl( - arg1 = key1, - arg2 = key2, - arg3 = Unit, - producer = producer, - callerRecomposeScope = currentRecomposeScope, - composer = currentComposer, - changed = 0, - invalidateCallerOnNewValue = false -) - -@PublishedApi -internal fun rSKC_before( - arg1: Any?, - arg2: Any?, - arg3: Any?, - composer: Composer, - changed: Int = 0, -): Boolean { - // Outer group is restartable: This should wrap the entire body of this function (except the actual - // return statement) and is what defines the recompose scope for producer. - // Key chosen "randomly" by mashing on my keyboard. - composer.startReplaceGroup(23975235) - - // Inner group is necessary to be able to skip calling producer. We need a nested group because we - // only want to skip calling producer, we still need to do other slot table stuff later to - // read the cache even if producer is skipped. - // Key chosen "randomly" by mashing on my keyboard. - composer.startReplaceGroup(-895983) - - // We don't consider producer in this change detection code since we don't want to treat it as a key. - var dirty = changed - if ((changed and 0b110) == 0) { - dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) - } - if ((changed and 0b110_000) == 0) { - dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) - } - if ((changed and 0b110_000_000) == 0) { - dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) - } - if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { - composer.skipToGroupEnd() - return false - } else { - return true - } -} - -@PublishedApi -internal fun rSKC_after( - newValue: Any?, - composer: Composer, -): R { - composer.endReplaceGroup() - - // Cache the return value in case we skipped above. Composer APIs require always reading the value - // first, and then calling updateRememberedValue the first time or optionally on subsequent - // recompositions. - val oldValue = composer.rememberedValue() - - @Suppress("SuspiciousEqualsCombination") - val returnValue = if ( - oldValue === Composer.Empty || - (newValue !== Composer.Empty && newValue != oldValue) - ) { - // Update the cache. - if (newValue === Composer.Empty) error("No new value was calculated.") - composer.updateRememberedValue(newValue) - newValue - } else { - // Producer was skipped (or returned the same value), return from the cache. - oldValue - } - - composer.endReplaceGroup() - @Suppress("UNCHECKED_CAST") - return returnValue as R -} - -@Suppress("SuspiciousEqualsCombination", "UNCHECKED_CAST") -@PublishedApi -internal fun rememberSkippableComposableImpl( - arg1: Any?, - arg2: Any?, - arg3: Any?, - producer: @Composable () -> R, - composer: Composer, - changed: Int, -): R { - // Outer group is restartable: This should wrap the entire body of this function (except the actual - // return statement) and is what defines the recompose scope for producer. - // Key chosen "randomly" by mashing on my keyboard. - composer.startReplaceGroup(23975235) - - // region Recompose producer - // Inner group is necessary to be able to skip calling producer. We need a nested group because we - // only want to skip calling producer, we still need to do other slot table stuff later to - // read the cache even if producer is skipped. - // Key chosen "randomly" by mashing on my keyboard. - composer.startReplaceGroup(-895983) - - // We don't consider producer in this change detection code since we don't want to treat it as a key. - var dirty = changed - if ((changed and 0b110) == 0) { - dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) - } - if ((changed and 0b110_000) == 0) { - dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) - } - if ((changed and 0b110_000_000) == 0) { - dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) - } - - val newValue = if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { - composer.skipToGroupEnd() - Composer.Empty - } else { - (producer as (Composer, Int) -> R).invoke(composer, 0) - } - - composer.endReplaceGroup() - // endregion - - // region Update cache - // Cache the return value in case we skipped above. Composer APIs require always reading the value - // first, and then calling updateRememberedValue the first time or optionally on subsequent - // recompositions. Identity comparison is intentional: the values cached here may be workflow - // renderings whose `equals` is allowed to throw or have side effects, so we must never call - // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys - // above; the only remaining job here is "did the producer run? if so, take its output". - val oldValue = composer.rememberedValue() - val returnValue = if (newValue === Composer.Empty) { - // Producer was skipped, return from the cache. - oldValue - } else { - // Producer ran, update the cache and return its new value. - composer.updateRememberedValue(newValue) - newValue - } - // endregion - - composer.endReplaceGroup() - return returnValue as R -} - -@Suppress("SuspiciousEqualsCombination", "UNCHECKED_CAST", "NAME_SHADOWING") -@PublishedApi -internal fun rememberSkippableAndRestartableComposableImpl( - arg1: Any?, - arg2: Any?, - arg3: Any?, - producer: @Composable () -> R, - callerRecomposeScope: RecomposeScope, - composer: Composer, - changed: Int, - invalidateCallerOnNewValue: Boolean -): R { - // Outer group is restartable: This should wrap the entire body of this function (except the actual - // return statement) and is what defines the recompose scope for producer. - // Key chosen "randomly" by mashing on my keyboard. - composer.startRestartGroup(23975234) - - // Only gets set if we end up composing producer this invocation. - var newValue: Any? = Composer.Empty - - // region Recompose producer - // Inner group is necessary to be able to skip calling producer. We need a nested group because we - // only want to skip calling producer, we still need to do other slot table stuff later to - // read the cache even if producer is skipped. - // Key chosen "randomly" by mashing on my keyboard. - composer.startReplaceGroup(-895982) - - // We don't consider producer in this change detection code since we don't want to treat it as a key. - var dirty = changed - if ((changed and 0b110) == 0) { - dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) - } - if ((changed and 0b110_000) == 0) { - dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) - } - if ((changed and 0b110_000_000) == 0) { - dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) - } - if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { - composer.skipToGroupEnd() - } else { - newValue = (producer as (Composer, Int) -> R).invoke(composer, 0) - } - - composer.endReplaceGroup() - // endregion - - // region Update cache - // Cache the return value in case we skipped above. Composer APIs require always reading the value - // first, and then calling updateRememberedValue the first time or optionally on subsequent - // recompositions. Identity comparison is intentional: the values cached here may be workflow - // renderings whose `equals` is allowed to throw or have side effects, so we must never call - // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys - // above; the only remaining job here is "did the producer run? if so, take its output". - val oldValue = composer.rememberedValue() - val returnValue = if (newValue === Composer.Empty) { - // Producer was skipped, return from the cache. - oldValue - } else { - // Producer ran, update the cache and return its new value. - composer.updateRememberedValue(newValue) - - // When we're recomposed directly, we obviously can't return returnValue to the original caller, - // so just invalidate it instead. It will eventually recompose after we're done in the same frame, - // and when it does so it should hit the cache (unless the caller passes a new producer). - if (invalidateCallerOnNewValue) { - callerRecomposeScope.invalidate() - } - newValue - } - // endregion - - composer.endRestartGroup()?.updateScope { composer, changed -> - // This lambda is called when producer is invalidated. The lambda must create a restartable group - // with the same key to preserve positional identity. - rememberSkippableAndRestartableComposableImpl( - arg1 = arg1, - arg2 = arg2, - arg3 = arg3, - producer = producer, - callerRecomposeScope = callerRecomposeScope, - composer = composer, - changed = changed, - invalidateCallerOnNewValue = true, - ) - } - return returnValue as R -} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt index d9a8b07245..8ce739e7dc 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -1,7 +1,14 @@ package com.squareup.workflow1.internal.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer +import androidx.compose.runtime.ExplicitGroupsComposable +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.currentRecomposeScope +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.internal.WorkflowNode import com.squareup.workflow1.internal.compose.ComposeRenderContext.Companion.rememberComposeRenderContext @@ -28,6 +35,7 @@ import com.squareup.workflow1.renderWorkflowIn * function by the parent workflow. This is only used to construct the child's [WorkflowSession], * and is not used for actual keying. [ComposeRenderContext] does the actual keying. */ +@OptIn(WorkflowExperimentalRuntime::class) @Composable internal fun renderWorkflow( workflow: Workflow, @@ -43,27 +51,218 @@ internal fun renderWorkflow( // Skip re-rendering when possible, but force recompose when new props or onOutput arrive. // We use the skippable+restartable variant so internal state-change invalidations trigger a fresh // call to the producer lambda within the same restart group. - // - // Notes on the previous TODOs (now resolved by enabling this path): - // - Plain "skipping without restarting" can't work for us: we have no access to the compiler- - // generated $changed parameter, so we can't tell whether stale-but-equal keys mean we should - // skip. The restartable variant sidesteps this by giving each call its own restart group whose - // invalidation is what we already drive from action application. - // - The "trampolining" cost is one captured composable lambda per renderWorkflow call. The - // benchmark deltas justify that allocation. - return rememberSkippableAndRestartableComposable(key1 = props, key2 = onOutput) { - val baseContext = rememberComposeRenderContext( + return if (COMPOSE_RUNTIME_SKIPPING in config.runtimeConfig) { + @Suppress("UNCHECKED_CAST") + return renderWorkflowRestartableImplComposable( + workflow, + props, + onOutput as ((Any?) -> Unit)?, + config, + parentSession, + renderKey, + false, // invalidateOnNewValue + currentRecomposeScope, + ) as RenderingT + } else { + renderWorkflowImpl( + workflow, + props, + onOutput, + config, + parentSession, + renderKey, + ) + } +} + +@Suppress("NOTHING_TO_INLINE") +@Composable +private inline fun renderWorkflowImpl( + workflow: Workflow, + props: PropsT, + noinline onOutput: ((OutputT) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, +): RenderingT { + val baseContext = rememberComposeRenderContext( + workflow = workflow, + props = props, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) + + // TODO this feels weird to have outside the context, should it be moved in too? Should this + // whole function be moved into the context? I think unit tests will be the only real forcing + // function, so let's write some tests and see how it works. + return baseContext.renderSelf(props) +} + +@Suppress("USELESS_CAST", "UNCHECKED_CAST") +private val renderWorkflowImplErased = @ExplicitGroupsComposable @Composable fun( + workflow: Workflow, + props: Any?, + onOutput: ((Any?) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, +): Any? { + return renderWorkflowImpl( + workflow = workflow, + props = props as Any?, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) +} as ( + Workflow<*, *, *>, + Any?, + ((Any?) -> Unit)?, + WorkflowComposableRuntimeConfig, + WorkflowSession?, + String, + Composer, + Int +) -> Any? + +@Suppress("UNCHECKED_CAST") +private val renderWorkflowRestartableImpl = fun( + workflow: Workflow<*, *, *>, + props: Any?, + onOutput: ((Any?) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, + invalidateCallerOnNewValue: Boolean, + callerRecomposeScope: RecomposeScope, + composer: Composer, + changed: Int +): Any? { + // Outer group is restartable: This should wrap the entire body of this function (except the actual + // return statement) and is what defines the recompose scope for producer. + // Key chosen "randomly" by mashing on my keyboard. + composer.startRestartGroup(23975234) + + // Only gets set if we end up composing producer this invocation. + var newValue: Any? = Composer.Empty + + // region Recompose producer + // Inner group is necessary to be able to skip calling producer. We need a nested group because we + // only want to skip calling producer, we still need to do other slot table stuff later to + // read the cache even if producer is skipped. + // Key chosen "randomly" by mashing on my keyboard. + composer.startReplaceGroup(-895982) + + // Many parameters to this function will never change between recompositions so we don't need to + // check them here. + val arg1 = props + val arg2 = onOutput + var dirty = changed + if ((changed and 0b110) == 0) { + dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) + } + if ((changed and 0b110_000) == 0) { + dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) + } + // if ((changed and 0b110_000_000) == 0) { + // dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) + // } + if ((dirty and 0b010_011) == 0b010_010 && composer.skipping) { + composer.skipToGroupEnd() + } else { + newValue = renderWorkflowImplErased( + workflow, + props, + onOutput, + config, + parentSession, + renderKey, + composer, + 0 + ) + } + + composer.endReplaceGroup() + // endregion + + // region Update cache + // Cache the return value in case we skipped above. Composer APIs require always reading the value + // first, and then calling updateRememberedValue the first time or optionally on subsequent + // recompositions. Identity comparison is intentional: the values cached here may be workflow + // renderings whose `equals` is allowed to throw or have side effects, so we must never call + // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys + // above; the only remaining job here is "did the producer run? if so, take its output". + val oldValue = composer.rememberedValue() + val returnValue = if (newValue === Composer.Empty) { + // Producer was skipped, return from the cache. + oldValue + } else { + // Producer ran, update the cache and return its new value. + composer.updateRememberedValue(newValue) + + // When we're recomposed directly, we obviously can't return returnValue to the original caller, + // so just invalidate it instead. It will eventually recompose after we're done in the same frame, + // and when it does so it should hit the cache (unless the caller passes a new producer). + if (invalidateCallerOnNewValue) { + callerRecomposeScope.invalidate() + } + newValue + } + // endregion + + composer.endRestartGroup()?.updateScope { composer, changed -> + // This lambda is called when producer is invalidated. The lambda must create a restartable + // group with the same key to preserve positional identity. + restartRenderWorkflowRestartableImpl( workflow = workflow, props = props, onOutput = onOutput, config = config, parentSession = parentSession, renderKey = renderKey, + callerRecomposeScope = callerRecomposeScope, + composer = composer, + changed = changed ) - - // TODO this feels weird to have outside the context, should it be moved in too? Should this - // whole function be moved into the context? I think unit tests will be the only real forcing - // function, so let's write some tests and see how it works. - baseContext.renderSelf(props) } + return returnValue } + +@Suppress("UNCHECKED_CAST") +private val renderWorkflowRestartableImplComposable = renderWorkflowRestartableImpl as @Composable ( + Workflow<*, *, *>, + Any?, + ((Any?) -> Unit)?, + WorkflowComposableRuntimeConfig, + WorkflowSession?, + String, + Boolean, + RecomposeScope, +) -> Any? + +@Suppress("UNCHECKED_CAST") +private fun restartRenderWorkflowRestartableImpl( + workflow: Workflow<*, *, *>, + props: Any?, + onOutput: ((Any?) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, + callerRecomposeScope: RecomposeScope, + composer: Composer, + changed: Int +): Any? = renderWorkflowRestartableImpl.invoke( + workflow, + props, + onOutput, + config, + parentSession, + renderKey, + true, // invalidateCallerOnNewValue + callerRecomposeScope, + composer, + changed +) diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt deleted file mode 100644 index 90338bf07f..0000000000 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RememberComposableTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.squareup.workflow1.internal.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertSame - -internal class RememberComposableTest { - - @Test fun rememberSkippableAndRestartable_runs_producer_on_first_compose() = runTest { - val test = TestComposition(backgroundScope) - try { - var calls = 0 - val producer: @Composable () -> Int = { calls++; 42 } - val result = test.recompose { - rememberSkippableAndRestartableComposable(key1 = "a", key2 = "b", producer = producer) - } - assertEquals(42, result) - assertEquals(1, calls) - } finally { - test.close() - } - } - - @Test fun rememberSkippableAndRestartable_skips_producer_when_keys_unchanged() = runTest { - val test = TestComposition(backgroundScope) - try { - val trigger = mutableStateOf(0) - var calls = 0 - val producer: @Composable () -> String = { calls++; "rendering" } - val content: @Composable () -> String = { - // Read trigger so the caller invalidates when it changes. - trigger.value - rememberSkippableAndRestartableComposable("k1", "k2", producer) - } - test.recompose(content) - trigger.value = 1 - val r2 = test.recompose(content) - assertEquals(1, calls, "Producer should be skipped when keys are unchanged") - assertEquals("rendering", r2) - } finally { - test.close() - } - } - - @Test fun rememberSkippableAndRestartable_runs_producer_when_key1_changes() = runTest { - val test = TestComposition(backgroundScope) - try { - var calls = 0 - val producer: @Composable () -> Int = { calls++; calls } - val key1Box = mutableStateOf("a") - val content: @Composable () -> Int = { - rememberSkippableAndRestartableComposable(key1Box.value, "fixed", producer) - } - test.recompose(content) - assertEquals(1, calls) - key1Box.value = "b" - test.recompose(content) - assertEquals(2, calls) - } finally { - test.close() - } - } - - @Test fun rememberSkippableAndRestartable_runs_producer_when_key2_changes() = runTest { - val test = TestComposition(backgroundScope) - try { - var calls = 0 - val producer: @Composable () -> Int = { calls++; calls } - val key2Box = mutableStateOf(0) - val content: @Composable () -> Int = { - rememberSkippableAndRestartableComposable("fixed", key2Box.value, producer) - } - test.recompose(content) - assertEquals(1, calls) - key2Box.value = 1 - test.recompose(content) - assertEquals(2, calls) - } finally { - test.close() - } - } - - @Test fun rememberSkippableAndRestartable_caches_with_identity_not_equals() = runTest { - // Key invariant: workflow renderings are allowed to have throwing equals/hashCode (this is - // tested at the runtime level by `exceptions_from_renderings_equals_methods_do_not_fail_runtime`). - // The cache check in rememberSkippable*Composable must therefore use identity, not equals. - class ThrowingEquals(val v: Int) { - override fun equals(other: Any?): Boolean = error("equals called!") - override fun hashCode(): Int = error("hashCode called!") - } - - val test = TestComposition(backgroundScope) - try { - val trigger = mutableStateOf(0) - var producerCalls = 0 - val producer: @Composable () -> ThrowingEquals = { - producerCalls++ - ThrowingEquals(producerCalls) - } - val content: @Composable () -> ThrowingEquals = { - trigger.value - rememberSkippableAndRestartableComposable("k1", "k2", producer) - } - val first = test.recompose(content) - trigger.value = 1 - val second = test.recompose(content) - // Producer was skipped, returning the same instance from the cache. Crucially: equals was - // never invoked, so the throwing equals didn't fire. - assertSame(first, second) - } finally { - test.close() - } - } - - @Test fun rememberSkippableComposable_runs_producer_on_first_compose() = runTest { - val test = TestComposition(backgroundScope) - try { - var calls = 0 - val producer: @Composable () -> Int = { calls++; 7 } - val result = test.recompose { - rememberSkippableComposable(key1 = "a", key2 = "b", producer = producer) - } - assertEquals(7, result) - assertEquals(1, calls) - } finally { - test.close() - } - } - - @Test fun rememberSkippableComposable_runs_producer_when_keys_change() = runTest { - val test = TestComposition(backgroundScope) - try { - var calls = 0 - val producer: @Composable () -> Int = { calls++; calls } - val key1Box = mutableStateOf("a") - val content: @Composable () -> Int = { - rememberSkippableComposable(key1Box.value, "fixed", producer) - } - test.recompose(content) - assertEquals(1, calls) - key1Box.value = "b" - test.recompose(content) - assertEquals(2, calls) - } finally { - test.close() - } - } - - @Test fun rememberSkippableComposable_skips_producer_when_keys_unchanged() = runTest { - val test = TestComposition(backgroundScope) - try { - val trigger = mutableStateOf(0) - var calls = 0 - val producer: @Composable () -> Int = { calls++; calls } - val content: @Composable () -> Int = { - trigger.value - rememberSkippableComposable("k1", "k2", producer) - } - test.recompose(content) - trigger.value = 1 - test.recompose(content) - assertEquals(1, calls) - } finally { - test.close() - } - } -} From 350e17017f25fdd650b7d4714a4d9e7d2a6b165b Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Wed, 13 May 2026 13:00:56 -0700 Subject: [PATCH 12/13] Invalidate all the way up the tree when a child changes. This is faster than trampolining. Also fixed the skipping code path to actually enable skipping in production. --- .../com/squareup/workflow1/RenderWorkflow.kt | 1 + .../internal/compose/ComposeRenderContext.kt | 32 +- .../internal/compose/RenderWorkflow.kt | 140 +++--- .../compose/runtime/SynchronizedMolecule.kt | 42 +- .../internal/compose/RenderWorkflowTest.kt | 411 ++++++++++++++++++ ...WorkflowRuntimeMultithreadingStressTest.kt | 4 +- 6 files changed, 514 insertions(+), 116 deletions(-) create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 6fbdbc87cb..d2389c4cfe 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -162,6 +162,7 @@ public fun renderWorkflowIn( interceptor = chainedInterceptor, workflowTracer = workflowTracer, onOutput = onOutput, + runtimeConfig = runtimeConfig, ) } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt index 3dc1a44d11..ef38029691 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt @@ -67,7 +67,8 @@ internal class ComposeRenderContext private constructor( override val renderKey: String, ) : BaseRenderContext, Sink>, - WorkflowSession { + WorkflowSession, + RecomposeScope by recomposeScope { private val interceptedWorkflow: StatefulWorkflow private val state: PeekableMutableState @@ -181,7 +182,8 @@ internal class ComposeRenderContext private constructor( onOutput = childOnOutput, config = config, parentSession = this, - renderKey = key + renderKey = key, + recomposeScope = this, ) } @@ -230,7 +232,9 @@ internal class ComposeRenderContext private constructor( applyActionLock.withLock { val oldState = state.value val (newState, applied) = action.applyTo(lastProps, oldState) - state.setWithInvalidator(newState, invalidator = { recomposeScope.invalidate() }) + state.setWithInvalidator(newState, invalidator = { + recomposeScope.invalidate() + }) // Propagate the output up the workflow tree. Propagation doesn't touch our state but still // should be part of the critical section: For an intermediate node, while it's propagating @@ -259,6 +263,16 @@ internal class ComposeRenderContext private constructor( } } + private class JoinedRecomposeScope( + private val scope1: RecomposeScope, + private val scope2: RecomposeScope, + ) : RecomposeScope { + override fun invalidate() { + scope1.invalidate() + scope2.invalidate() + } + } + companion object { /** * Hard-coded group key used for all groups that are created by [ComposeRenderContext]s. @@ -278,19 +292,17 @@ internal class ComposeRenderContext private constructor( config: WorkflowComposableRuntimeConfig, parentSession: WorkflowSession?, renderKey: String, + callerRecomposeScope: RecomposeScope, ): ComposeRenderContext { val workflowScope = rememberCoroutineScope() - // The only reason we put the scope in a state is because if we just capture it directly - // inside the rememberSaveable lambda, for some reason compose starts freaking out, groups get - // removed for no reason and the RecomposeScope becomes invalid. - // TODO find a less gross way to do this. - val recomposeScope by rememberUpdatedState(currentRecomposeScope) + // When state changes, invalidate as much as possible in one go to reduce trampolining. + val joinedRecomposeScope = JoinedRecomposeScope(callerRecomposeScope, currentRecomposeScope) val renderContext: ComposeRenderContext = rememberSaveable( saver = Saver( initialProps = props, workflow = workflow, workflowScope = workflowScope, - recomposeScope = recomposeScope, + recomposeScope = joinedRecomposeScope, config = config, parentSession = parentSession, renderKey = renderKey, @@ -299,7 +311,7 @@ internal class ComposeRenderContext private constructor( ComposeRenderContext( initialProps = props, workflow = workflow, - recomposeScope = recomposeScope, + recomposeScope = joinedRecomposeScope, workflowScope = workflowScope, config = config, parent = parentSession, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt index 8ce739e7dc..0f198bac5f 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -2,10 +2,10 @@ package com.squareup.workflow1.internal.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.Composer -import androidx.compose.runtime.ExplicitGroupsComposable import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.currentComposer import androidx.compose.runtime.currentRecomposeScope -import com.squareup.workflow1.RuntimeConfigOptions +import androidx.compose.runtime.staticCompositionLocalOf import com.squareup.workflow1.RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowExperimentalRuntime @@ -14,6 +14,8 @@ import com.squareup.workflow1.internal.WorkflowNode import com.squareup.workflow1.internal.compose.ComposeRenderContext.Companion.rememberComposeRenderContext import com.squareup.workflow1.renderWorkflowIn +internal val LocalRootRecomposeScope = staticCompositionLocalOf { error("Not set") } + /** * This is the entry point for hosting a workflow tree inside a composition. It manages all the * bookkeeping for the workflow session. It's analogous to [WorkflowNode] in the traditional @@ -35,7 +37,9 @@ import com.squareup.workflow1.renderWorkflowIn * function by the parent workflow. This is only used to construct the child's [WorkflowSession], * and is not used for actual keying. [ComposeRenderContext] does the actual keying. */ +@Suppress("UNCHECKED_CAST") @OptIn(WorkflowExperimentalRuntime::class) +// @ExplicitGroupsComposable @Composable internal fun renderWorkflow( workflow: Workflow, @@ -44,6 +48,7 @@ internal fun renderWorkflow( config: WorkflowComposableRuntimeConfig, parentSession: WorkflowSession?, renderKey: String, + recomposeScope: RecomposeScope = currentRecomposeScope, ): RenderingT { // The lifetime of the workflow session is tied to the workflow.identifier, but we don't key on it // here since it's already keyed from ComposeRenderContext. @@ -52,39 +57,44 @@ internal fun renderWorkflow( // We use the skippable+restartable variant so internal state-change invalidations trigger a fresh // call to the producer lambda within the same restart group. return if (COMPOSE_RUNTIME_SKIPPING in config.runtimeConfig) { - @Suppress("UNCHECKED_CAST") - return renderWorkflowRestartableImplComposable( - workflow, - props, - onOutput as ((Any?) -> Unit)?, - config, - parentSession, - renderKey, - false, // invalidateOnNewValue - currentRecomposeScope, + return renderWorkflowRestartableImpl( + workflow = workflow, + props = props, + onOutput = onOutput as ((Any?) -> Unit)?, + config = config, + parentSession = parentSession, + renderKey = renderKey, + invalidateCallerOnNewValue = false, + // Note: If this is recomposeScope, we'll invalidate the entire path up the tree and recompose + // in one go. If it's currentRecomposeScope, we'll trampoline. Benchmarks show that doing a + // single recompose pass is roughly twice as fast as trampolining in some cases, so we do that + // as an optimization. + callerRecomposeScope = recomposeScope, + composer = currentComposer, + changed = 0 ) as RenderingT } else { renderWorkflowImpl( - workflow, + workflow as Workflow, props, - onOutput, + onOutput as ((Any?) -> Unit)?, config, parentSession, renderKey, - ) + recomposeScope, + ) as RenderingT } } -@Suppress("NOTHING_TO_INLINE") -@Composable -private inline fun renderWorkflowImpl( - workflow: Workflow, - props: PropsT, - noinline onOutput: ((OutputT) -> Unit)?, +private val renderWorkflowImpl = @Composable fun( + workflow: Workflow, + props: Any?, + onOutput: ((Any?) -> Unit)?, config: WorkflowComposableRuntimeConfig, parentSession: WorkflowSession?, renderKey: String, -): RenderingT { + recomposeScope: RecomposeScope, +): Any? { val baseContext = rememberComposeRenderContext( workflow = workflow, props = props, @@ -92,6 +102,7 @@ private inline fun renderWorkflowImpl( config = config, parentSession = parentSession, renderKey = renderKey, + callerRecomposeScope = recomposeScope, ) // TODO this feels weird to have outside the context, should it be moved in too? Should this @@ -101,35 +112,20 @@ private inline fun renderWorkflowImpl( } @Suppress("USELESS_CAST", "UNCHECKED_CAST") -private val renderWorkflowImplErased = @ExplicitGroupsComposable @Composable fun( - workflow: Workflow, - props: Any?, - onOutput: ((Any?) -> Unit)?, - config: WorkflowComposableRuntimeConfig, - parentSession: WorkflowSession?, - renderKey: String, -): Any? { - return renderWorkflowImpl( - workflow = workflow, - props = props as Any?, - onOutput = onOutput, - config = config, - parentSession = parentSession, - renderKey = renderKey, - ) -} as ( +private val renderWorkflowImplComposable = renderWorkflowImpl as ( Workflow<*, *, *>, Any?, ((Any?) -> Unit)?, WorkflowComposableRuntimeConfig, WorkflowSession?, String, + RecomposeScope, Composer, Int ) -> Any? @Suppress("UNCHECKED_CAST") -private val renderWorkflowRestartableImpl = fun( +private fun renderWorkflowRestartableImpl( workflow: Workflow<*, *, *>, props: Any?, onOutput: ((Any?) -> Unit)?, @@ -158,8 +154,9 @@ private val renderWorkflowRestartableImpl = fun( // Many parameters to this function will never change between recompositions so we don't need to // check them here. - val arg1 = props - val arg2 = onOutput + val arg1 = workflow + val arg2 = props + val arg3 = onOutput var dirty = changed if ((changed and 0b110) == 0) { dirty = changed or (if (composer.changed(arg1)) 0b100 else 0b010) @@ -167,21 +164,26 @@ private val renderWorkflowRestartableImpl = fun( if ((changed and 0b110_000) == 0) { dirty = dirty or (if (composer.changed(arg2)) 0b100_000 else 0b010_000) } - // if ((changed and 0b110_000_000) == 0) { - // dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) - // } - if ((dirty and 0b010_011) == 0b010_010 && composer.skipping) { + if ((changed and 0b110_000_000) == 0) { + dirty = dirty or (if (composer.changed(arg3)) 0b100_000_000 else 0b010_000_000) + } + if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { composer.skipToGroupEnd() } else { - newValue = renderWorkflowImplErased( + newValue = renderWorkflowImplComposable( workflow, props, onOutput, config, parentSession, renderKey, + callerRecomposeScope, composer, - 0 + // changed: We can tell it exactly what changed. Each group of three corresponds to a param, + // with the rightmost group being the first parameter (workflow). + // TODO remember if props/onOutput changed from above logic, pass those flags. + // TODO use this value to avoid re-comparing inside this function. + 0b010_010_010_000_000_000 ) } @@ -196,7 +198,8 @@ private val renderWorkflowRestartableImpl = fun( // `equals` on them. Skipping decisions are already driven by composer.changed() on the keys // above; the only remaining job here is "did the producer run? if so, take its output". val oldValue = composer.rememberedValue() - val returnValue = if (newValue === Composer.Empty) { + val returnValue = + if (oldValue !== Composer.Empty && (newValue === Composer.Empty || newValue === oldValue)) { // Producer was skipped, return from the cache. oldValue } else { @@ -216,13 +219,14 @@ private val renderWorkflowRestartableImpl = fun( composer.endRestartGroup()?.updateScope { composer, changed -> // This lambda is called when producer is invalidated. The lambda must create a restartable // group with the same key to preserve positional identity. - restartRenderWorkflowRestartableImpl( + renderWorkflowRestartableImpl( workflow = workflow, props = props, onOutput = onOutput, config = config, parentSession = parentSession, renderKey = renderKey, + invalidateCallerOnNewValue = true, callerRecomposeScope = callerRecomposeScope, composer = composer, changed = changed @@ -230,39 +234,3 @@ private val renderWorkflowRestartableImpl = fun( } return returnValue } - -@Suppress("UNCHECKED_CAST") -private val renderWorkflowRestartableImplComposable = renderWorkflowRestartableImpl as @Composable ( - Workflow<*, *, *>, - Any?, - ((Any?) -> Unit)?, - WorkflowComposableRuntimeConfig, - WorkflowSession?, - String, - Boolean, - RecomposeScope, -) -> Any? - -@Suppress("UNCHECKED_CAST") -private fun restartRenderWorkflowRestartableImpl( - workflow: Workflow<*, *, *>, - props: Any?, - onOutput: ((Any?) -> Unit)?, - config: WorkflowComposableRuntimeConfig, - parentSession: WorkflowSession?, - renderKey: String, - callerRecomposeScope: RecomposeScope, - composer: Composer, - changed: Int -): Any? = renderWorkflowRestartableImpl.invoke( - workflow, - props, - onOutput, - config, - parentSession, - renderKey, - true, // invalidateCallerOnNewValue - callerRecomposeScope, - composer, - changed -) diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt index 7c8c57c3a1..85be898cb9 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt @@ -198,33 +198,39 @@ private class RealSynchronizedMolecule( } // Synchronously recompose any invalidated composables, if any, and update lastResult. - val frameRequest = tryGetFrameRequest() + var frameRequest = tryGetFrameRequest() if (frameRequest == null) { if (!lastResult.isInitialized) { error("Expected initial composition to synchronously request initial frame.") } } else { - // Hard-code unchanging frame time since there's no actual frame time code shouldn't rely on - // this value. - val frameResult = frameRequest.execute(0L) - - // If the composition threw an exception, re-throw it ourselves now instead of waiting for the - // scope to get it, since lastResult may have not been initialized in this case and we'd throw - // below and get suppressed. - frameResult.exceptionOrNull()?.let { - // This exception has the stack trace inside the composables, but FrameRequest already adds - // a suppressed exception on it that includes the stack trace of this recomposeWithContent - // call. - throw it - } + do { + // Hard-code unchanging frame time since there's no actual frame time code shouldn't rely on + // this value. + val frameResult = frameRequest!!.execute(0L) + + // If the composition threw an exception, re-throw it ourselves now instead of waiting for the + // scope to get it, since lastResult may have not been initialized in this case and we'd throw + // below and get suppressed. + frameResult.exceptionOrNull()?.let { + // This exception has the stack trace inside the composables, but FrameRequest already adds + // a suppressed exception on it that includes the stack trace of this recomposeWithContent + // call. + throw it + } + + // If the composition threw an exception, we want it to cancel the coroutine scope before + // getOrThrow below does so. + recomposeDispatcher.advanceUntilIdle() - // If the composition threw an exception, we want it to cancel the coroutine scope before - // getOrThrow below does so. - recomposeDispatcher.advanceUntilIdle() + // Note: If any RecomposeScope was explicitly invalidated during composition, there will be + // another frame request waiting here. + frameRequest = tryGetFrameRequest() + } while (frameRequest != null) } @Suppress("UNCHECKED_CAST") - return lastResult.getOrThrow() as R + return (lastResult.getOrThrow() as R) } @OptIn(ExperimentalStdlibApi::class) diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt new file mode 100644 index 0000000000..3bb49be159 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt @@ -0,0 +1,411 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.mutableStateOf +import app.cash.burst.Burst +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.StatelessWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.action +import com.squareup.workflow1.internal.IdCounter +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.stateless +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(WorkflowExperimentalRuntime::class) +enum class TestConfig(val runtimeOptions: RuntimeOptions) { + COMPOSE_NON_SKIPPING(RuntimeOptions.COMPOSE_RUNTIME_NON_SKIPPING), + COMPOSE_SKIPPING(RuntimeOptions.COMPOSE_RUNTIME_SKIPPING), +} + +@Burst +@OptIn(WorkflowExperimentalRuntime::class) +internal class RenderWorkflowTest( + val config: TestConfig = TestConfig.COMPOSE_SKIPPING +) { + + @BeforeTest fun setUp() { + enableImmediateApplyForTests() + } + + private val skippingConfig = WorkflowComposableRuntimeConfig( + runtimeConfig = config.runtimeOptions.runtimeConfig, + idCounter = IdCounter(), + ) + + @Test fun skips_render_when_props_and_onOutput_unchanged() = runTest { + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + val workflow = Workflow.stateless { props -> + renderCount++ + props * 2 + } + val onOutput: (Nothing) -> Unit = {} + // Force the surrounding composable to recompose between calls without changing any of + // renderWorkflow's parameters. This is what exercises the skipping path: the restart group + // around renderWorkflow is reachable on recomposition, but its dirty bits show all params + // are unchanged, so the inner producer should be skipped. + val tick = mutableStateOf(0) + val content: @Composable () -> Int = { + tick.value + renderWorkflow( + workflow = workflow, + props = 5, + onOutput = onOutput, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + var expectedRenderCount = 1 + assertEquals(10, test.recompose(content)) + assertEquals(expectedRenderCount, renderCount) + + tick.value = 1 + if (RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING !in config.runtimeOptions.runtimeConfig) { + expectedRenderCount++ + } + assertEquals(10, test.recompose(content)) + assertEquals(expectedRenderCount, renderCount) + + tick.value = 2 + if (RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING !in config.runtimeOptions.runtimeConfig) { + expectedRenderCount++ + } + assertEquals(10, test.recompose(content)) + assertEquals(expectedRenderCount, renderCount) + } finally { + test.close() + } + } + + @Test fun rerenders_when_props_change() = runTest { + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + val workflow = Workflow.stateless { props -> + renderCount++ + props * 2 + } + val onOutput: (Nothing) -> Unit = {} + val propsState = mutableStateOf(5) + val content: @Composable () -> Int = { + renderWorkflow( + workflow = workflow, + props = propsState.value, + onOutput = onOutput, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(10, test.recompose(content)) + assertEquals(1, renderCount) + + propsState.value = 7 + assertEquals(14, test.recompose(content)) + assertEquals(2, renderCount) + + propsState.value = 11 + assertEquals(22, test.recompose(content)) + assertEquals(3, renderCount) + } finally { + test.close() + } + } + + @Test fun rerenders_when_onOutput_changes() = runTest { + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + val workflow = Workflow.stateless { props -> + renderCount++ + props * 2 + } + val onOutputState = mutableStateOf<((Nothing) -> Unit)?>(null) + val content: @Composable () -> Int = { + renderWorkflow( + workflow = workflow, + props = 5, + onOutput = onOutputState.value, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(10, test.recompose(content)) + assertEquals(1, renderCount) + + onOutputState.value = {} + assertEquals(10, test.recompose(content)) + assertEquals(2, renderCount) + + onOutputState.value = {} + assertEquals(10, test.recompose(content)) + assertEquals(3, renderCount) + } finally { + test.close() + } + } + + @Test fun rerenders_when_internal_state_changes() = runTest { + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + var capturedSink: Sink>? = null + val workflow = object : StatefulWorkflow() { + override fun initialState( + props: Int, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Int, + renderState: Int, + context: RenderContext, + ): Int { + renderCount++ + capturedSink = context.actionSink + return renderProps + renderState + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val onOutput: (Nothing) -> Unit = {} + val content: @Composable () -> Int = { + renderWorkflow( + workflow = workflow, + props = 5, + onOutput = onOutput, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(5, test.recompose(content)) + assertEquals(1, renderCount) + val sink = checkNotNull(capturedSink) { "expected actionSink to be captured during render" } + + // Sending an action that updates state should invalidate renderWorkflow's restart group, + // forcing a new render even though props/onOutput are unchanged. + println("OMG test: setting state") + sink.send(action("setStateTo3") { state = 3 }) + println("OMG test: triggering recompose") + assertEquals(8, test.recompose(content)) + assertEquals(2, renderCount) + + sink.send(action("setStateTo10") { state = 10 }) + assertEquals(15, test.recompose(content)) + assertEquals(3, renderCount) + } finally { + test.close() + } + } + + @Test fun rerenders_parent_when_internal_state_changes_rendering() = runTest { + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + var capturedSink: Sink>? = null + val childWorkflow = object : StatefulWorkflow() { + override fun initialState( + props: Int, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Int, + renderState: Int, + context: RenderContext, + ): Int { + println("OMG test: recomposing child") + capturedSink = context.actionSink + return (renderProps + renderState).also { println("OMG test: new child rendering: $it") } + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val parentWorkflow = object : StatefulWorkflow() { + override fun initialState( + props: Int, + snapshot: Snapshot? + ): Unit = Unit + + override fun render( + renderProps: Int, + renderState: Unit, + context: RenderContext, + ): Int { + println("OMG test: recomposing parent") + renderCount++ + val childRendering = context.renderChild(childWorkflow, props = renderProps) + return childRendering.also { println("OMG test: new parent rendering: $it") } + } + + override fun snapshotState(state: Unit): Snapshot? = null + } + val onOutput: (Nothing) -> Unit = {} + val content: @Composable () -> Int = { + withCompositionLocals(LocalRootRecomposeScope provides currentRecomposeScope) { + println("OMG test: recomposing root (recomposeScope=$currentRecomposeScope)") + renderWorkflow( + workflow = parentWorkflow, + props = 5, + onOutput = onOutput, + config = skippingConfig, + parentSession = null, + renderKey = "", + ).also { println("OMG test: new root rendering: $it") } + } + } + + assertEquals(5, test.recompose(content)) + assertEquals(1, renderCount) + val sink = checkNotNull(capturedSink) { "expected actionSink to be captured during render" } + + // Sending an action that updates state should invalidate renderWorkflow's restart group, + // forcing a new render even though props/onOutput are unchanged. + println("OMG test: setting state…") + sink.send(action("setStateTo3") { state = 3 }) + println("OMG test: triggering recompose") + assertEquals(8, test.recompose(content)) + assertEquals(2, renderCount) + + println("OMG test: setting state again…") + sink.send(action("setStateTo10") { state = 10 }) + assertEquals(15, test.recompose(content)) + assertEquals(3, renderCount) + } finally { + test.close() + } + } + + @Test fun skips_siblings_when_child_internal_state_changes() = runTest { + val isSkipping = RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING in config.runtimeOptions.runtimeConfig + + val test = TestComposition(backgroundScope) + try { + var renderCount = 0 + var capturedSink: Sink>? = null + val leaf0Workflow = object : StatelessWorkflow() { + override fun render( + renderProps: Int, + context: RenderContext + ): Int { + println("OMG test: recomposing leaf0") + renderCount++ + return renderProps.also { println("OMG test: new leaf0 rendering: $it") } + } + } + val leaf1Workflow = object : StatefulWorkflow() { + override fun initialState( + props: Int, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Int, + renderState: Int, + context: RenderContext, + ): Int { + println("OMG test: recomposing leaf1") + renderCount++ + capturedSink = context.actionSink + return (renderProps + renderState).also { println("OMG test: new leaf1 rendering: $it") } + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val childWorkflow = object : StatelessWorkflow() { + override fun render( + renderProps: Int, + context: RenderContext + ): Int { + println("OMG test: recomposing child") + renderCount++ + val leaf0Rendering = + context.renderChild(leaf0Workflow, key = "leaf0", props = renderProps) + val leaf1Rendering = + context.renderChild(leaf1Workflow, key = "leaf1", props = renderProps) + val leaf2Rendering = + context.renderChild(leaf0Workflow, key = "leaf2", props = renderProps) + return (leaf0Rendering + leaf1Rendering + leaf2Rendering) + .also { println("OMG test: new child rendering: $it") } + } + } + val parentWorkflow = object : StatefulWorkflow() { + override fun initialState( + props: Int, + snapshot: Snapshot? + ): Unit = Unit + + override fun render( + renderProps: Int, + renderState: Unit, + context: RenderContext, + ): Int { + println("OMG test: recomposing parent") + renderCount++ + val child1Rendering = + context.renderChild(childWorkflow, key = "child1", props = renderProps) + val child2Rendering = + context.renderChild(childWorkflow, key = "child2", props = renderProps) + return (child1Rendering + child2Rendering).also { println("OMG test: new parent rendering: $it") } + } + + override fun snapshotState(state: Unit): Snapshot? = null + } + val onOutput: (Nothing) -> Unit = {} + val content: @Composable () -> Int = { + withCompositionLocals(LocalRootRecomposeScope provides currentRecomposeScope) { + println("OMG test: recomposing root (recomposeScope=$currentRecomposeScope)") + renderWorkflow( + workflow = parentWorkflow, + props = 5, + onOutput = onOutput, + config = skippingConfig, + parentSession = null, + renderKey = "", + ).also { println("OMG test: new root rendering: $it") } + } + } + + assertEquals(30, test.recompose(content)) + var expectedRenderCount = 9 + assertEquals(expectedRenderCount, renderCount) + val sink = checkNotNull(capturedSink) { "expected actionSink to be captured during render" } + + // Sending an action that updates state should invalidate renderWorkflow's restart group, + // forcing a new render even though props/onOutput are unchanged. + println("OMG test: setting state…") + sink.send(action("setStateTo3") { state = 3 }) + println("OMG test: triggering recompose") + assertEquals(33, test.recompose(content)) + expectedRenderCount += if (isSkipping) 3 else 9 + assertEquals(expectedRenderCount, renderCount) + + println("OMG test: setting state again…") + sink.send(action("setStateTo10") { state = 10 }) + assertEquals(40, test.recompose(content)) + expectedRenderCount += if (isSkipping) 3 else 9 + assertEquals(expectedRenderCount, renderCount) + } finally { + test.close() + } + } +} diff --git a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt index dd2dacd81f..a3a569f91e 100644 --- a/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt +++ b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt @@ -1,7 +1,7 @@ package com.squareup.workflow1 import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions -import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.COMPOSE_RUNTIME_ONLY +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.COMPOSE_RUNTIME_SKIPPING import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -27,7 +27,7 @@ import kotlin.test.assertEquals // @Burst class WorkflowRuntimeMultithreadingStressTest( ) { - private val runtime: RuntimeOptions = COMPOSE_RUNTIME_ONLY + private val runtime: RuntimeOptions = COMPOSE_RUNTIME_SKIPPING @Before fun setUp() { From 1e91278015a5c0255c0aecd6207a8e9cc9d7a8a7 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 14 May 2026 10:18:33 -0700 Subject: [PATCH 13/13] Combine workflow render-pass state into WorkflowSnapshotState. Replaces PeekableMutableState with WorkflowSnapshotState, a custom StateObject that holds props, onOutput, and state in a single record. updateAndGetState short-circuits writes when nothing changed, and applyAction only writes when the state actually differs. Includes unit tests covering construction, peekState, updateAndGetState short-circuit paths, applyAction state writes and output propagation, and snapshot isolation. Microbenchmark deltas (ComposeSkipping vs ComposeNoSkip, SquareishTree, SM-A256U1): - rerenderSingleSiblingViaStateChange: 14.8x faster (204us vs 3.02ms), 34x fewer allocs (351 vs 11,943) - rerenderSingleSiblingByPropsChange: 1.8x faster (1.82ms vs 3.33ms), 2.5x fewer allocs - wideSiblingKeys_rerenderSingleSiblingByPropsChange: 2.4x faster (504us vs 1.20ms), 4x fewer allocs - initialRenderNewSibling: 1.7x faster, 2.4x fewer allocs - tearDownSingleSibling: 1.6x faster, 2.4x fewer allocs ComposeSkipping now beats the legacy AllOpt runtime on sibling-rerender workloads where unchanged siblings are skippable (rerenderSingleSiblingViaStateChange Squareish: 0.21x of AllOpt). --- .../internal/compose/ComposeRenderContext.kt | 98 +++-- .../internal/compose/PeekableMutableState.kt | 45 --- .../internal/compose/RenderWorkflow.kt | 36 +- .../internal/compose/WorkflowSnapshotState.kt | 150 ++++++++ .../compose/PeekableMutableStateTest.kt | 107 ------ .../internal/compose/RenderWorkflowTest.kt | 182 ++++++++++ .../compose/WorkflowSnapshotStateTest.kt | 340 ++++++++++++++++++ 7 files changed, 737 insertions(+), 221 deletions(-) delete mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotState.kt delete mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt create mode 100644 workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotStateTest.kt diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt index ef38029691..f8d4bdc8b1 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt @@ -3,13 +3,12 @@ package com.squareup.workflow1.internal.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.ExplicitGroupsComposable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.RecomposeScope -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.currentRecomposeScope import androidx.compose.runtime.getValue @@ -31,7 +30,6 @@ import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer -import com.squareup.workflow1.applyTo import com.squareup.workflow1.identifier import com.squareup.workflow1.intercept import com.squareup.workflow1.internal.Lock @@ -47,7 +45,6 @@ import com.squareup.workflow1.trace import com.squareup.workflow1.traceNoFinally import com.squareup.workflow1.workflowSessionToString import kotlinx.coroutines.CoroutineScope -import kotlin.concurrent.Volatile import kotlin.coroutines.CoroutineContext import kotlin.reflect.KType @@ -71,13 +68,10 @@ internal class ComposeRenderContext private constructor( RecomposeScope by recomposeScope { private val interceptedWorkflow: StatefulWorkflow - private val state: PeekableMutableState private val applyActionLock = Lock() private var trapdoor: Trapdoor? by threadLocalOf { null } - // Render params from last render pass that are only used by actions, and don't need to be states. - @Volatile private var onOutput: ((O) -> Unit)? = null - @Volatile private var lastProps = initialProps + private val state: WorkflowSnapshotState override val actionSink: Sink> get() = this @@ -111,16 +105,43 @@ internal class ComposeRenderContext private constructor( workflowScope = workflowScope, ) } - state = PeekableMutableState(initialState) + state = WorkflowSnapshotState( + props = initialProps, + onOutput = null, + state = initialState + ) } - @Composable - fun renderSelf(props: P): R = withTrapdoor { - interceptedWorkflow.render( + @Suppress("UNCHECKED_CAST") + fun renderSelf( + props: P, + onOutput: ((Any?) -> Unit)?, + didPropsChange: Boolean?, + didOnOutputChange: Boolean?, + composer: Composer, + ): R { + trapdoor = Trapdoor(composer) + val currentState = state.updateAndGetState( + props, + onOutput, + didPropsChange = didPropsChange, + didOnOutputChange = didOnOutputChange + ) { oldProps, oldState -> + workflowTracer.traceNoFinally(OnPropsChanged) { + interceptedWorkflow.onPropsChanged( + old = oldProps as P, + new = props, + state = oldState + ) + } + } + return interceptedWorkflow.render( renderProps = props, - renderState = state.value, + renderState = currentState, context = RenderContext(this, interceptedWorkflow) - ) + ).also { + trapdoor = null + } } override fun send(value: WorkflowAction) { @@ -190,32 +211,6 @@ internal class ComposeRenderContext private constructor( private fun requireTrapdoor(operationName: String): Trapdoor = trapdoor ?: error("Cannot perform $operationName on RenderContext outside of render pass.") - // Since we need to manually deal with props diffing ourselves, tell compose not to generate its - // own checking code which would result in redundant .equals() calls. - @Suppress("ComposableNaming") - @NonRestartableComposable - @Composable - private fun updatePropsAndOutput( - props: P, - onOutput: ((O) -> Unit)?, - ) { - // Use the slot table to detect props changes. - Trapdoor.runIfValueChanged(props) { oldProps -> - workflowTracer.traceNoFinally(OnPropsChanged) { - @Suppress("UNCHECKED_CAST") - state.value = interceptedWorkflow.onPropsChanged( - old = oldProps, - new = props, - state = state.value - ) - } - } - SideEffect { - this.lastProps = props - this.onOutput = onOutput - } - } - private fun onDisposed() { config.workflowInterceptor?.onSessionCancelled( cause = null, @@ -230,25 +225,15 @@ internal class ComposeRenderContext private constructor( check(trapdoor == null) { "Cannot send to action sink from render pass." } // Send can be called from any thread so wrap non-atomic reads/writes in a critical section. applyActionLock.withLock { - val oldState = state.value - val (newState, applied) = action.applyTo(lastProps, oldState) - state.setWithInvalidator(newState, invalidator = { - recomposeScope.invalidate() - }) - - // Propagate the output up the workflow tree. Propagation doesn't touch our state but still - // should be part of the critical section: For an intermediate node, while it's propagating - // an action from one part of its subtree, if another action comes in from a different part - // of the subtree, the second action won't propagate until the first one is done. If we - // moved this out of the lock, then the propagations of two actions from two subtrees could - // be interleaved to their common ancestors. - onOutput?.let { onOutput -> - applied.output?.value?.let(onOutput) - } + @Suppress("UNCHECKED_CAST") + state.applyAction( + action as WorkflowAction, + onNewState = { recomposeScope.invalidate() } + ) } } - private fun snapshot(): Snapshot? = interceptedWorkflow.snapshotState(state.value) + private fun snapshot(): Snapshot? = interceptedWorkflow.snapshotState(state.peekState()) // No need for the overhead of groups, this is only used in a very specific way when we're already // thinking about groups. @@ -319,7 +304,6 @@ internal class ComposeRenderContext private constructor( snapshot = null, ) } - renderContext.updatePropsAndOutput(props, onOutput) // Values remembered by rememberSaveable don't get RememberObserver callbacks so we need to // use an effect for it. diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt deleted file mode 100644 index b9c0a0c1b9..0000000000 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableState.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.squareup.workflow1.internal.compose - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.snapshots.StateObject -import androidx.compose.runtime.snapshots.StateRecord -import androidx.compose.runtime.snapshots.withCurrent -import androidx.compose.runtime.snapshots.writable - -internal class PeekableMutableState(initialValue: T) : MutableState, StateObject { - private var record = Record(initialValue) - - override var value: T - get() = record.withCurrent { it.value } - set(newValue) { - setWithInvalidator(newValue, invalidator = null) - } - - override fun component1(): T = value - override fun component2(): (T) -> Unit = { value = it } - - fun setWithInvalidator( - newValue: T, - invalidator: (() -> Unit)? - ) { - if (newValue != value) { - record.writable(this) { value = newValue } - invalidator?.invoke() - } - } - - override val firstStateRecord: StateRecord - get() = record - - override fun prependStateRecord(value: StateRecord) { - @Suppress("UNCHECKED_CAST") - record = value as Record - } - - private class Record(var value: T) : StateRecord() { - override fun create(): StateRecord = Record(value) - override fun assign(value: StateRecord) { - this.value = (value as Record).value - } - } -} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt index 0f198bac5f..1418c45a71 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -57,7 +57,7 @@ internal fun renderWorkflow( // We use the skippable+restartable variant so internal state-change invalidations trigger a fresh // call to the producer lambda within the same restart group. return if (COMPOSE_RUNTIME_SKIPPING in config.runtimeConfig) { - return renderWorkflowRestartableImpl( + renderWorkflowRestartableImpl( workflow = workflow, props = props, onOutput = onOutput as ((Any?) -> Unit)?, @@ -74,7 +74,7 @@ internal fun renderWorkflow( changed = 0 ) as RenderingT } else { - renderWorkflowImpl( + val renderContext = renderWorkflowImpl( workflow as Workflow, props, onOutput as ((Any?) -> Unit)?, @@ -82,6 +82,13 @@ internal fun renderWorkflow( parentSession, renderKey, recomposeScope, + ) + renderContext.renderSelf( + props = props, + onOutput = onOutput, + didPropsChange = null, + didOnOutputChange = null, + composer = currentComposer, ) as RenderingT } } @@ -94,8 +101,8 @@ private val renderWorkflowImpl = @Composable fun( parentSession: WorkflowSession?, renderKey: String, recomposeScope: RecomposeScope, -): Any? { - val baseContext = rememberComposeRenderContext( +): ComposeRenderContext { + return rememberComposeRenderContext( workflow = workflow, props = props, onOutput = onOutput, @@ -104,11 +111,6 @@ private val renderWorkflowImpl = @Composable fun( renderKey = renderKey, callerRecomposeScope = recomposeScope, ) - - // TODO this feels weird to have outside the context, should it be moved in too? Should this - // whole function be moved into the context? I think unit tests will be the only real forcing - // function, so let's write some tests and see how it works. - return baseContext.renderSelf(props) } @Suppress("USELESS_CAST", "UNCHECKED_CAST") @@ -122,7 +124,7 @@ private val renderWorkflowImplComposable = renderWorkflowImpl as ( RecomposeScope, Composer, Int -) -> Any? +) -> ComposeRenderContext @Suppress("UNCHECKED_CAST") private fun renderWorkflowRestartableImpl( @@ -170,7 +172,7 @@ private fun renderWorkflowRestartableImpl( if ((dirty and 0b010_010_011) == 0b010_010_010 && composer.skipping) { composer.skipToGroupEnd() } else { - newValue = renderWorkflowImplComposable( + val renderContext = renderWorkflowImplComposable( workflow, props, onOutput, @@ -185,6 +187,15 @@ private fun renderWorkflowRestartableImpl( // TODO use this value to avoid re-comparing inside this function. 0b010_010_010_000_000_000 ) + + newValue = renderContext.renderSelf( + props = props, + onOutput = onOutput, + // Reuse the change information we already calculated so we don't have to call equals again. + didPropsChange = (dirty and 0b100_000) != 0, + didOnOutputChange = (dirty and 0b100_000_000) != 0, + composer = composer, + ) } composer.endReplaceGroup() @@ -229,7 +240,8 @@ private fun renderWorkflowRestartableImpl( invalidateCallerOnNewValue = true, callerRecomposeScope = callerRecomposeScope, composer = composer, - changed = changed + // Set bits indicating that none of workflow, props, nor onOutput changed. + changed = changed or 0b010_010_010 ) } return returnValue diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotState.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotState.kt new file mode 100644 index 0000000000..8703de6aba --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotState.kt @@ -0,0 +1,150 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.snapshots.StateObject +import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.withCurrent +import androidx.compose.runtime.snapshots.writable +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.applyTo + +/** + * Custom snapshot [StateObject] that stores the state for a workflow session (props, state, and + * output handler). + * + * None of the accessors notify read observers—it is expected that the recompose scope responsible + * for the render pass will be explicitly invalidated when [applyAction] updates the state. + */ +internal class WorkflowSnapshotState( + props: Any?, + onOutput: ((Any?) -> Unit)?, + state: Any?, +) : StateObject { + private var record = Record(props, onOutput, state) + + override val firstStateRecord: StateRecord + get() = record + + override fun prependStateRecord(value: StateRecord) { + record = value as Record + } + + /** Returns the workflow's current state without notifying read observers. */ + fun peekState(): Any? = record.withCurrent { it.state } + + /** + * Performs all the state update duties for a render pass: + * + * - Stores new props and output handlers. + * - Calls the workflow's [onPropsChanged] method only if necessary, and updates the workflow's + * state if a new one is returned. + * - Returns the latest state for the workflow to be used by the render method. + * - Avoids calling `equals` on [newProps] and [newOnOutput] if [didPropsChange] and + * [didOnOutputChange] are not null. + * + * This should be called once every render pass. + * + * @param didPropsChange Optional flag that can be passed if the caller has already determined if + * the props are different from the last call (e.g. via Compose's mechanisms). If null, the props' + * `equals` method will be used. NB: If this is `false`, then [newProps] won't be saved even if + * it's different than the last props. + * @param didOnOutputChange Optional flag that can be passed if the caller has already determined + * if the output handler is different from the last call. NB: If this is `false`, then + * [newOnOutput] won't be saved even if it's different than the last output handler. + * @param onPropsChanged Handler invoked when props are different. Should call + * [com.squareup.workflow1.StatefulWorkflow.onPropsChanged]. + */ + inline fun updateAndGetState( + newProps: Any?, + noinline newOnOutput: ((Any?) -> Unit)?, + didPropsChange: Boolean?, + didOnOutputChange: Boolean?, + onPropsChanged: (oldProps: Any?, oldState: Any?) -> Any? + ): Any? { + var oldProps: Any? = null + var oldOnOutput: ((Any?) -> Unit)? = null + var oldState: Any? = null + + // Avoid recording a read since that would make the write below a backwards write, and + // potentially trigger and infinite recomposition loop. + record.withCurrent { + oldProps = it.props + oldOnOutput = it.onOutput + oldState = it.state + } + + var newState = oldState + var stateChanged = false + val propsChanged = didPropsChange == true || (didPropsChange == null && oldProps != newProps) + if (propsChanged) { + newState = onPropsChanged(oldProps, oldState) + // Only call `equals` if the state may have changed. + stateChanged = oldState != newState + } + + // Only perform a state write if necessary. Note there is no additional cost to updating all + // record fields if we have to update one, so we just write them all every time. + if (propsChanged || + stateChanged || + (didOnOutputChange == true || (didOnOutputChange == null && oldOnOutput != newOnOutput)) + ) { + record.writable(this) { + props = newProps + onOutput = newOnOutput + state = newState + } + } + + // No need to report a read at all, since the only way the state can change (other than this + // function) is via applyAction, which explicitly invalidates the RecomposeScope. + + return newState + } + + /** + * Applies a [WorkflowAction] to the workflow's state, writing the new state and invoking the + * output handler if necessary. + */ + inline fun applyAction( + action: WorkflowAction, + onNewState: () -> Unit + ) { + var oldState: Any? = null + var lastProps: Any? = null + var onOutput: ((Any?) -> Unit)? = null + record.withCurrent { + lastProps = it.props + onOutput = it.onOutput + oldState = it.state + } + val (newState, applied) = action.applyTo(lastProps, oldState) + if (oldState != newState) { + record.writable(this) { state = newState } + onNewState() + } + + // Propagate the output up the workflow tree. Propagation doesn't touch our state but still + // should be part of the critical section: For an intermediate node, while it's propagating + // an action from one part of its subtree, if another action comes in from a different part + // of the subtree, the second action won't propagate until the first one is done. If we + // moved this out of the lock, then the propagations of two actions from two subtrees could + // be interleaved to their common ancestors. + onOutput?.let { onOutput -> + applied.output?.value?.let(onOutput) + } + } + + internal class Record( + var props: Any?, + var onOutput: ((Any?) -> Unit)?, + var state: Any?, + ) : StateRecord() { + override fun create(): StateRecord = Record(props, onOutput, state) + + override fun assign(value: StateRecord) { + value as Record + props = value.props + onOutput = value.onOutput + state = value.state + } + } +} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt deleted file mode 100644 index 4ea8ad5c59..0000000000 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/PeekableMutableStateTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.squareup.workflow1.internal.compose - -import androidx.compose.runtime.snapshots.Snapshot -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotSame - -internal class PeekableMutableStateTest { - - @BeforeTest fun setUp() { - // PeekableMutableState is a snapshot StateObject; writing to it triggers the global write - // observer registered by GlobalSnapshotManager. Without this flag, the observer tries to - // launch on Dispatchers.Main, which isn't installed in plain JVM tests. - enableImmediateApplyForTests() - } - - @Test fun returns_initial_value() { - val state = PeekableMutableState("init") - assertEquals("init", state.value) - } - - @Test fun assignment_updates_value() { - val state = PeekableMutableState("init") - state.value = "next" - assertEquals("next", state.value) - } - - @Test fun component1_reads_and_component2_writes() { - val state = PeekableMutableState(1) - val (read, write) = state - assertEquals(1, read) - write(42) - assertEquals(42, state.value) - } - - @Test fun setWithInvalidator_invokes_invalidator_on_change() { - val state = PeekableMutableState("init") - var calls = 0 - state.setWithInvalidator("changed") { calls++ } - assertEquals(1, calls) - assertEquals("changed", state.value) - } - - @Test fun setWithInvalidator_skips_invalidator_when_value_equals_existing() { - val state = PeekableMutableState("same") - var calls = 0 - state.setWithInvalidator("same") { calls++ } - assertEquals(0, calls) - assertEquals("same", state.value) - } - - @Test fun setWithInvalidator_accepts_null_invalidator() { - val state = PeekableMutableState(0) - state.setWithInvalidator(7, invalidator = null) - assertEquals(7, state.value) - } - - @Test fun assignment_does_not_invoke_an_invalidator() { - // The plain `value =` setter is documented to delegate to setWithInvalidator with null. This - // test pins that contract: writes via the setter should not trigger any side effect beyond - // updating the value (i.e., the no-op null invalidator path). - val state = PeekableMutableState("init") - state.value = "next" - // No assertion beyond no-throw — the contract is "no invalidator to invoke". - assertEquals("next", state.value) - } - - @Test fun snapshot_isolation_writes_are_invisible_until_apply() { - val state = PeekableMutableState("init") - val snapshot = Snapshot.takeMutableSnapshot() - try { - snapshot.enter { state.value = "in-snapshot" } - // Outside the snapshot, the global view still sees the original value. - assertEquals("init", state.value) - snapshot.apply().check() - assertEquals("in-snapshot", state.value) - } finally { - snapshot.dispose() - } - } - - @Test fun snapshot_isolation_reads_inside_snapshot_see_writes_inside_snapshot() { - val state = PeekableMutableState("init") - val snapshot = Snapshot.takeMutableSnapshot() - try { - snapshot.enter { - state.value = "in-snapshot" - assertEquals("in-snapshot", state.value) - } - } finally { - snapshot.dispose() - } - } - - @Test fun setWithInvalidator_uses_equals_not_identity() { - // Verifies that the equality check uses .equals(), not identity. Two distinct list instances - // with the same content should compare equal so the invalidator is not invoked. - val original = listOf(1, 2, 3) - val newButEqual = listOf(1, 2, 3) - assertNotSame(original, newButEqual) - val state = PeekableMutableState(original) - var calls = 0 - state.setWithInvalidator(newButEqual) { calls++ } - assertEquals(0, calls) - } -} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt index 3bb49be159..95c9321708 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt @@ -2,7 +2,10 @@ package com.squareup.workflow1.internal.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import app.cash.burst.Burst import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions @@ -408,4 +411,183 @@ internal class RenderWorkflowTest( test.close() } } + + @Test fun `props equals only called once when rerendered from state change`() = runTest { + // Non-skipping code path isn't optimized for this. + if (RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING !in skippingConfig.runtimeConfig) { + return@runTest + } + val test = TestComposition(backgroundScope) + + var propsEqualsCount = 0 + + class Props { + override fun equals(other: Any?): Boolean { + propsEqualsCount++ + return other === this + } + } + + try { + var capturedSink: Sink>? = null + val workflow = object : StatefulWorkflow() { + override fun initialState( + props: Props, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Props, + renderState: Int, + context: RenderContext + ): Int { + capturedSink = context.actionSink + return renderState + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val content = @Composable { + renderWorkflow( + workflow = workflow, + props = Props(), + onOutput = null, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(0, test.recompose(content)) + assertEquals(0, propsEqualsCount) + + capturedSink!!.send(action("setStateTo3") { state = 3 }) + + assertEquals(3, test.recompose(content)) + assertEquals(1, propsEqualsCount) + } finally { + test.close() + } + } + + @Test fun `props equals only called once when rerendered with props change`() = runTest { + // Non-skipping code path isn't optimized for this. + if (RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING !in skippingConfig.runtimeConfig) { + return@runTest + } + val test = TestComposition(backgroundScope) + + var propsEqualsCount = 0 + + class Props { + override fun equals(other: Any?): Boolean { + propsEqualsCount++ + return other === this + } + } + + try { + // Don't store props in a state since MutableState rights do their own equals check. + var props = Props() + var recomposeTrigger by mutableIntStateOf(0) + val workflow = object : StatefulWorkflow() { + override fun initialState( + props: Props, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Props, + renderState: Int, + context: RenderContext + ): Int { + return renderState + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val content = @Composable { + recomposeTrigger + renderWorkflow( + workflow = workflow, + props = props, + onOutput = null, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(0, test.recompose(content)) + assertEquals(0, propsEqualsCount) + + props = Props() + recomposeTrigger++ + + assertEquals(0, test.recompose(content)) + assertEquals(1, propsEqualsCount) + } finally { + test.close() + } + } + @Test fun `props equals only called once when rerendered without props change`() = runTest { + // Non-skipping code path isn't optimized for this. + if (RuntimeConfigOptions.COMPOSE_RUNTIME_SKIPPING !in skippingConfig.runtimeConfig) { + return@runTest + } + + val test = TestComposition(backgroundScope) + + var propsEqualsCount = 0 + + class Props { + override fun equals(other: Any?): Boolean { + propsEqualsCount++ + return other === this + } + } + + try { + // Don't store props in a state since MutableState rights do their own equals check. + val props = Props() + var recomposeTrigger by mutableIntStateOf(0) + val workflow = object : StatefulWorkflow() { + override fun initialState( + props: Props, + snapshot: Snapshot? + ): Int = 0 + + override fun render( + renderProps: Props, + renderState: Int, + context: RenderContext + ): Int { + return renderState + } + + override fun snapshotState(state: Int): Snapshot? = null + } + val content = @Composable { + recomposeTrigger + renderWorkflow( + workflow = workflow, + props = props, + onOutput = null, + config = skippingConfig, + parentSession = null, + renderKey = "", + ) + } + + assertEquals(0, test.recompose(content)) + assertEquals(0, propsEqualsCount) + + recomposeTrigger++ + + assertEquals(0, test.recompose(content)) + assertEquals(1, propsEqualsCount) + } finally { + test.close() + } + } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotStateTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotStateTest.kt new file mode 100644 index 0000000000..edce56c61f --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/WorkflowSnapshotStateTest.kt @@ -0,0 +1,340 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail + +internal class WorkflowSnapshotStateTest { + + @BeforeTest fun setUp() { + // WorkflowSnapshotState is a snapshot StateObject; writes go through the global write + // observer registered by GlobalSnapshotManager. Without this flag, the observer tries to + // launch on Dispatchers.Main, which isn't installed in plain JVM tests. + enableImmediateApplyForTests() + } + + /** + * Runs [block] inside a mutable snapshot and reports whether any write was observed against + * [target]. The snapshot is applied at the end so its effects become visible outside. + */ + private fun observeWritesTo(target: WorkflowSnapshotState, block: () -> Unit): Boolean { + var observed = false + val snapshot = Snapshot.takeMutableSnapshot(writeObserver = { written -> + if (written === target) observed = true + }) + try { + snapshot.enter(block) + snapshot.apply().check() + } finally { + snapshot.dispose() + } + return observed + } + + // Construction / accessors ----------------------------------------------------------------- + + @Test fun constructor_stores_initial_values_and_peekState_returns_initial_state() { + val onOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + assertEquals("s", state.peekState()) + } + + @Test fun firstStateRecord_matches_initial_values() { + val onOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + val record = state.firstStateRecord as WorkflowSnapshotState.Record + assertEquals("p", record.props) + assertSame(onOutput, record.onOutput) + assertEquals("s", record.state) + } + + @Test fun record_create_returns_fresh_copy_with_current_values() { + val onOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + val original = state.firstStateRecord as WorkflowSnapshotState.Record + val copy = original.create() as WorkflowSnapshotState.Record + + assertNotSame(original, copy) + assertEquals(original.props, copy.props) + assertSame(original.onOutput, copy.onOutput) + assertEquals(original.state, copy.state) + } + + @Test fun record_assign_copies_fields_from_source() { + val src = WorkflowSnapshotState.Record(props = "p2", onOutput = { /* noop */ }, state = "s2") + val dst = WorkflowSnapshotState.Record(props = "p1", onOutput = null, state = "s1") + dst.assign(src) + assertEquals(src.props, dst.props) + assertSame(src.onOutput, dst.onOutput) + assertEquals(src.state, dst.state) + } + + @Test fun prependStateRecord_replaces_head_record() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s") + val replacement = WorkflowSnapshotState.Record(props = "p2", onOutput = null, state = "s2") + state.prependStateRecord(replacement) + assertSame(replacement, state.firstStateRecord) + } + + // updateAndGetState ------------------------------------------------------------------------ + + @Test fun updateAndGetState_no_change_does_not_write() { + val onOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + var propsChangedCalls = 0 + val wrote = observeWritesTo(state) { + val returned = state.updateAndGetState( + newProps = "p", + newOnOutput = onOutput, + didPropsChange = false, + didOnOutputChange = false, + ) { _, _ -> + propsChangedCalls++ + fail("onPropsChanged should not be invoked") + } + assertEquals("s", returned) + } + assertEquals(0, propsChangedCalls) + assertEquals(false, wrote) + assertEquals("s", state.peekState()) + } + + @Test fun updateAndGetState_invokes_onPropsChanged_when_didPropsChange_true() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s1") + var seen: Pair? = null + val returned = state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = true, + didOnOutputChange = false, + ) { oldProps, oldState -> + seen = oldProps to oldState + "s-from-handler" + } + assertEquals("p1" to "s1", seen) + assertEquals("s-from-handler", returned) + assertEquals("s-from-handler", state.peekState()) + } + + @Test fun updateAndGetState_honors_didPropsChange_true_even_when_props_equal() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s1") + var calls = 0 + val returned = state.updateAndGetState( + newProps = "p", + newOnOutput = null, + didPropsChange = true, + didOnOutputChange = false, + ) { _, _ -> + calls++ + "s2" + } + assertEquals(1, calls) + assertEquals("s2", returned) + assertEquals("s2", state.peekState()) + } + + @Test fun updateAndGetState_honors_didPropsChange_false_even_when_props_differ() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s") + val returned = state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = false, + didOnOutputChange = false, + ) { _, _ -> + fail("onPropsChanged should not be invoked when didPropsChange is false") + } + assertEquals("s", returned) + // The caller asserted "nothing changed", so no write occurs at all — the new props value + // is silently ignored. This documents the contract: didPropsChange=false trusts the caller + // even when it's a lie. + val record = state.firstStateRecord as WorkflowSnapshotState.Record + assertEquals("p1", record.props) + } + + @Test fun updateAndGetState_null_flag_skips_onPropsChanged_when_equal_by_equals() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s") + var calls = 0 + val returned = state.updateAndGetState( + newProps = "p", + newOnOutput = null, + didPropsChange = null, + didOnOutputChange = null, + ) { _, _ -> + calls++ + "should-not-be-used" + } + assertEquals(0, calls) + assertEquals("s", returned) + } + + @Test fun updateAndGetState_null_flag_invokes_onPropsChanged_when_not_equal() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s1") + var calls = 0 + val returned = state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = null, + didOnOutputChange = null, + ) { _, _ -> + calls++ + "s2" + } + assertEquals(1, calls) + assertEquals("s2", returned) + assertEquals("s2", state.peekState()) + } + + @Test fun updateAndGetState_onPropsChanged_returning_oldState_still_updates_props() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s") + val returned = state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = true, + didOnOutputChange = false, + ) { _, oldState -> + // Return the existing state, signalling state didn't change. + oldState + } + assertEquals("s", returned) + val record = state.firstStateRecord as WorkflowSnapshotState.Record + assertEquals("p2", record.props) + assertEquals("s", record.state) + } + + @Test fun updateAndGetState_onOutput_only_change_writes_new_onOutput() { + val initialOnOutput: (Any?) -> Unit = {} + val newOnOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = initialOnOutput, state = "s") + val wrote = observeWritesTo(state) { + val returned = state.updateAndGetState( + newProps = "p", + newOnOutput = newOnOutput, + didPropsChange = false, + didOnOutputChange = true, + ) { _, _ -> + fail("onPropsChanged should not be invoked when didPropsChange is false") + } + assertEquals("s", returned) + } + assertEquals(true, wrote) + val record = state.firstStateRecord as WorkflowSnapshotState.Record + assertSame(newOnOutput, record.onOutput) + } + + @Test fun updateAndGetState_all_unchanged_skips_write() { + val onOutput: (Any?) -> Unit = {} + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + val wrote = observeWritesTo(state) { + state.updateAndGetState( + newProps = "p", + newOnOutput = onOutput, + didPropsChange = false, + didOnOutputChange = false, + ) { _, _ -> fail("onPropsChanged should not be invoked") } + } + assertEquals(false, wrote) + } + + @Test fun updateAndGetState_writes_participate_in_snapshot_isolation() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s1") + val snapshot = Snapshot.takeMutableSnapshot() + try { + snapshot.enter { + state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = true, + didOnOutputChange = false, + ) { _, _ -> "s2" } + // Inside the snapshot, the new value is visible. + assertEquals("s2", state.peekState()) + } + // Outside the snapshot, the write is still invisible until apply(). + assertEquals("s1", state.peekState()) + snapshot.apply().check() + assertEquals("s2", state.peekState()) + } finally { + snapshot.dispose() + } + } + + // applyAction ------------------------------------------------------------------------------ + + @Test fun applyAction_no_state_change_does_not_invoke_onNewState_or_write() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s") + var newStateCalls = 0 + val noOp: WorkflowAction = action("noop") { /* leave state alone */ } + val wrote = observeWritesTo(state) { + state.applyAction(noOp) { newStateCalls++ } + } + assertEquals(0, newStateCalls) + assertEquals(false, wrote) + assertEquals("s", state.peekState()) + } + + @Test fun applyAction_state_change_invokes_onNewState_and_updates_peekState() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s1") + var newStateCalls = 0 + val setState: WorkflowAction = action("set") { this.state = "s2" } + state.applyAction(setState) { newStateCalls++ } + assertEquals(1, newStateCalls) + assertEquals("s2", state.peekState()) + } + + @Test fun applyAction_with_output_invokes_stored_onOutput() { + val outputs = mutableListOf() + val onOutput: (Any?) -> Unit = { outputs += it } + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s") + val emit: WorkflowAction = action("emit") { setOutput("the-output") } + state.applyAction(emit) { fail("state did not change; onNewState should not be invoked") } + assertEquals(listOf("the-output"), outputs) + } + + @Test fun applyAction_with_output_and_null_onOutput_does_not_crash() { + val state = WorkflowSnapshotState(props = "p", onOutput = null, state = "s") + val emit: WorkflowAction = action("emit") { setOutput("dropped") } + state.applyAction(emit) { fail("state did not change; onNewState should not be invoked") } + // No assertion needed beyond no-throw; state remains unchanged. + assertEquals("s", state.peekState()) + } + + @Test fun applyAction_with_state_and_output_invokes_both_callbacks_in_order() { + val callOrder = mutableListOf() + val onOutput: (Any?) -> Unit = { callOrder += "output=$it" } + val state = WorkflowSnapshotState(props = "p", onOutput = onOutput, state = "s1") + val both: WorkflowAction = action("both") { + this.state = "s2" + setOutput("out") + } + state.applyAction(both) { callOrder += "state=${state.peekState()}" } + // The action helper writes state first, then propagates output. + assertEquals(listOf("state=s2", "output=out"), callOrder) + assertEquals("s2", state.peekState()) + } + + @Test fun applyAction_uses_latest_props_after_updateAndGetState() { + val state = WorkflowSnapshotState(props = "p1", onOutput = null, state = "s") + state.updateAndGetState( + newProps = "p2", + newOnOutput = null, + didPropsChange = true, + didOnOutputChange = false, + ) { _, oldState -> oldState } + + var seenProps: Any? = null + val capture: WorkflowAction = action("capture") { + seenProps = props + } + state.applyAction(capture) { fail("state did not change; onNewState should not be invoked") } + assertEquals("p2", seenProps) + // Sanity check: applyAction observed the latest props rather than the initial one. + assertTrue(seenProps != "p1") + } +}