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..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 @@ -16,6 +15,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,10 +25,13 @@ 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 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") @@ -36,8 +39,10 @@ import kotlin.test.assertEquals enum class BenchmarkRuntimeOptions( val runtimeConfig: RuntimeConfig ) { - NoOptimizations(RuntimeOptions.NONE.runtimeConfig), + // NoOptimizations(RuntimeOptions.NONE.runtimeConfig), AllOptimizations(RuntimeOptions.ALL.runtimeConfig), + ComposeNoSkip(RuntimeOptions.COMPOSE_RUNTIME_NON_SKIPPING.runtimeConfig), + ComposeSkipping(RuntimeOptions.COMPOSE_RUNTIME_SKIPPING.runtimeConfig), } enum class BenchmarkTreeShape( @@ -52,17 +57,30 @@ 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 { 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() + @Before fun setUp() { + setGlobalSnapshotManagerSendApplyImmediately(true) + } + + @After fun tearDown() { + setGlobalSnapshotManagerSendApplyImmediately(false) + } + @Test fun initialRenderAllChildren() = benchmarkWorkflowPropsChange( setupProps = BenchmarkWorkflowRoot.Props( renderFirstLeaf = false, @@ -251,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( @@ -284,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, 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..59378ae7e8 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -141,6 +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.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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04c83d1972..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 @@ -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/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/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-config/config-android/dependencies/releaseRuntimeClasspath.txt b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt index 805293158d..3391a043da 100644 --- a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt @@ -1,60 +1,98 @@ 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.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.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.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 @@ -64,4 +102,8 @@ 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 0608390b3e..88b7ff1586 100644 --- a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt +++ b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt @@ -1,5 +1,37 @@ +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.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 @@ -8,4 +40,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.3.20 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-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..19ccd44924 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,15 @@ public enum class RuntimeConfigOptions { */ @WorkflowExperimentalRuntime INDEXED_ACTIVE_STAGING_LISTS, + + /** + * Replaces the traditional Workflow runtime with the Compose runtime. + */ + @WorkflowExperimentalRuntime + COMPOSE_RUNTIME, + + @WorkflowExperimentalRuntime + COMPOSE_RUNTIME_SKIPPING, ; public companion object { @@ -125,10 +134,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 +484,14 @@ public enum class RuntimeConfigOptions { ) ), + 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 * 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/dependencies/androidRuntimeClasspath.txt b/workflow-runtime/dependencies/androidRuntimeClasspath.txt index 805293158d..3391a043da 100644 --- a/workflow-runtime/dependencies/androidRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/androidRuntimeClasspath.txt @@ -1,60 +1,98 @@ 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.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.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.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 @@ -64,4 +102,8 @@ 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 a5ce631af6..b243abd794 100644 --- a/workflow-runtime/dependencies/jsRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jsRuntimeClasspath.txt @@ -1,10 +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.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-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 043be4788c..88b7ff1586 100644 --- a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt @@ -1,22 +1,47 @@ -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.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.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: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.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.jetbrains:markdown-jvm:0.7.3 -org.jetbrains:markdown:0.7.3 -org.jsoup:jsoup:1.16.1 +org.jspecify:jspecify:1.0.0 diff --git a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt index ba73455712..5ef2e77dea 100644 --- a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt @@ -1,5 +1,37 @@ +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.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 @@ -7,4 +39,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.3.20 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/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..d2389c4cfe 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,19 @@ 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, + runtimeConfig = runtimeConfig, + ) + } + 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..f8d4bdc8b1 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeRenderContext.kt @@ -0,0 +1,344 @@ +@file:OptIn(WorkflowExperimentalApi::class) + +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.RecomposeScope +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.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.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, + RecomposeScope by recomposeScope { + + private val interceptedWorkflow: StatefulWorkflow + private val applyActionLock = Lock() + private var trapdoor: Trapdoor? by threadLocalOf { null } + + private val state: WorkflowSnapshotState + + 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 = WorkflowSnapshotState( + props = initialProps, + onOutput = null, + state = initialState + ) + } + + @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 = currentState, + context = RenderContext(this, interceptedWorkflow) + ).also { + trapdoor = null + } + } + + 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, + recomposeScope = this, + ) + } + + private fun requireTrapdoor(operationName: String): Trapdoor = + trapdoor ?: error("Cannot perform $operationName on RenderContext outside of render pass.") + + 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 { + @Suppress("UNCHECKED_CAST") + state.applyAction( + action as WorkflowAction, + onNewState = { recomposeScope.invalidate() } + ) + } + } + + 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. + @ExplicitGroupsComposable + @Composable + private inline fun withTrapdoor(block: () -> R): R { + Trapdoor.open { trapdoor -> + this.trapdoor = trapdoor + val result = block() + this.trapdoor = null + return result + } + } + + 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. + * 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, + callerRecomposeScope: RecomposeScope, + ): ComposeRenderContext { + val workflowScope = rememberCoroutineScope() + // 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 = joinedRecomposeScope, + config = config, + parentSession = parentSession, + renderKey = renderKey, + ) + ) { + ComposeRenderContext( + initialProps = props, + workflow = workflow, + recomposeScope = joinedRecomposeScope, + workflowScope = workflowScope, + config = config, + parent = parentSession, + renderKey = renderKey, + snapshot = null, + ) + } + + // 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/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/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt new file mode 100644 index 0000000000..1418c45a71 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflow.kt @@ -0,0 +1,248 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.staticCompositionLocalOf +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 +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 + * 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. + */ +@Suppress("UNCHECKED_CAST") +@OptIn(WorkflowExperimentalRuntime::class) +// @ExplicitGroupsComposable +@Composable +internal fun renderWorkflow( + workflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + 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. + + // 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. + return if (COMPOSE_RUNTIME_SKIPPING in config.runtimeConfig) { + 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 { + val renderContext = renderWorkflowImpl( + workflow as Workflow, + props, + onOutput as ((Any?) -> Unit)?, + config, + parentSession, + renderKey, + recomposeScope, + ) + renderContext.renderSelf( + props = props, + onOutput = onOutput, + didPropsChange = null, + didOnOutputChange = null, + composer = currentComposer, + ) as RenderingT + } +} + +private val renderWorkflowImpl = @Composable fun( + workflow: Workflow, + props: Any?, + onOutput: ((Any?) -> Unit)?, + config: WorkflowComposableRuntimeConfig, + parentSession: WorkflowSession?, + renderKey: String, + recomposeScope: RecomposeScope, +): ComposeRenderContext { + return rememberComposeRenderContext( + workflow = workflow, + props = props, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + callerRecomposeScope = recomposeScope, + ) +} + +@Suppress("USELESS_CAST", "UNCHECKED_CAST") +private val renderWorkflowImplComposable = renderWorkflowImpl as ( + Workflow<*, *, *>, + Any?, + ((Any?) -> Unit)?, + WorkflowComposableRuntimeConfig, + WorkflowSession?, + String, + RecomposeScope, + Composer, + Int +) -> ComposeRenderContext + +@Suppress("UNCHECKED_CAST") +private fun renderWorkflowRestartableImpl( + 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 = 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) + } + 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 { + val renderContext = renderWorkflowImplComposable( + workflow, + props, + onOutput, + config, + parentSession, + renderKey, + callerRecomposeScope, + composer, + // 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 + ) + + 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() + // 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 (oldValue !== Composer.Empty && (newValue === Composer.Empty || newValue === oldValue)) { + // 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. + renderWorkflowRestartableImpl( + workflow = workflow, + props = props, + onOutput = onOutput, + config = config, + parentSession = parentSession, + renderKey = renderKey, + invalidateCallerOnNewValue = true, + callerRecomposeScope = callerRecomposeScope, + composer = composer, + // 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/RenderWorkflowWithComposeRuntime.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt new file mode 100644 index 0000000000..7595c31030 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowWithComposeRuntime.kt @@ -0,0 +1,235 @@ +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 + +/** + * 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, + 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/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/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..85be898cb9 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt @@ -0,0 +1,338 @@ +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. + var frameRequest = tryGetFrameRequest() + if (frameRequest == null) { + if (!lastResult.isInitialized) { + error("Expected initial composition to synchronously request initial frame.") + } + } else { + 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() + + // 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) + } + + @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/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..95c9321708 --- /dev/null +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/compose/RenderWorkflowTest.kt @@ -0,0 +1,593 @@ +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 +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() + } + } + + @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/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/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") + } +} 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/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/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt b/workflow-runtime/src/jvmTest/kotlin/com/squareup/workflow1/WorkflowRuntimeMultithreadingStressTest.kt index 0488ef524b..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,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_SKIPPING 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_SKIPPING + + @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-testing/dependencies/runtimeClasspath.txt b/workflow-testing/dependencies/runtimeClasspath.txt index 18e923bd3e..4cbe8c1e20 100644 --- a/workflow-testing/dependencies/runtimeClasspath.txt +++ b/workflow-testing/dependencies/runtimeClasspath.txt @@ -1,7 +1,39 @@ +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.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 @@ -13,4 +45,8 @@ 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 13bd74ae57..8122fd2d31 100644 --- a/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing-papa/dependencies/releaseRuntimeClasspath.txt @@ -9,53 +9,91 @@ 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.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.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 @@ -65,4 +103,8 @@ 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 7b7703041c..3391a043da 100644 --- a/workflow-tracing/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-tracing/dependencies/releaseRuntimeClasspath.txt @@ -9,52 +9,90 @@ 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.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.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 @@ -64,4 +102,8 @@ 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 fbdb7c3817..0ff3b5adcb 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -2,80 +2,109 @@ 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.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 @@ -85,4 +114,8 @@ 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 5b75d1ff38..288ee60264 100644 --- a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt @@ -2,74 +2,103 @@ 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.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 @@ -79,4 +108,8 @@ 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/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 diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 80acf834d5..289102f604 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -1,69 +1,98 @@ 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.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.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.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 @@ -73,4 +102,8 @@ 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-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)) +} diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index c1ed820e87..c6064e4dbd 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -1,71 +1,100 @@ 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.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.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.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 @@ -75,4 +104,8 @@ 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