From 556e86a76b1ddee806c3d9595ea7b32ca3f8d111 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 16 Jun 2025 14:48:28 -0400 Subject: [PATCH 1/2] 1311: Add Android RenderWorkflow Tests --- settings.gradle.kts | 1 + workflow-core/api/workflow-core.api | 16 +- .../com/squareup/workflow1/HandlerBox.kt | 87 ++- .../com/squareup/workflow1/RuntimeConfig.kt | 10 + .../kotlin/com/squareup/workflow1/Sink.kt | 3 + .../com/squareup/workflow1/WorkerWorkflow.kt | 8 +- .../com/squareup/workflow1/WorkflowAction.kt | 25 +- workflow-runtime-android/README.md | 4 + .../api/workflow-runtime-android.api | 0 workflow-runtime-android/build.gradle.kts | 25 + workflow-runtime-android/gradle.properties | 3 + .../src/androidTest/AndroidManifest.xml | 6 + .../workflow1/AndroidRenderWorkflowInTest.kt | 500 ++++++++++++++++++ .../com/squareup/workflow1/RenderWorkflow.kt | 19 +- .../workflow1/internal/SubtreeManager.kt | 24 +- .../workflow1/internal/WorkflowNode.kt | 39 +- .../workflow1/internal/WorkflowRunner.kt | 29 +- .../workflow1/internal/SubtreeManagerTest.kt | 2 +- .../workflow1/internal/WorkflowNodeTest.kt | 18 +- .../workflow1/internal/WorkflowRunnerTest.kt | 34 +- 20 files changed, 772 insertions(+), 81 deletions(-) create mode 100644 workflow-runtime-android/README.md create mode 100644 workflow-runtime-android/api/workflow-runtime-android.api create mode 100644 workflow-runtime-android/build.gradle.kts create mode 100644 workflow-runtime-android/gradle.properties create mode 100644 workflow-runtime-android/src/androidTest/AndroidManifest.xml create mode 100644 workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index f4982b30f7..00f951b069 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include( ":workflow-config:config-jvm", ":workflow-core", ":workflow-runtime", + ":workflow-runtime-android", ":workflow-rx2", ":workflow-testing", ":workflow-tracing", diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 567217d4f0..53330a7111 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -35,6 +35,11 @@ public final class com/squareup/workflow1/BaseRenderContext$DefaultImpls { public static synthetic fun renderChild$default (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } +public final class com/squareup/workflow1/DeferredActionToBeApplied : com/squareup/workflow1/ActionProcessingResult { + public fun (Lkotlinx/coroutines/Deferred;)V + public final fun getApplyAction ()Lkotlinx/coroutines/Deferred; +} + public final class com/squareup/workflow1/HandlerBox1 { public field handler Lkotlin/jvm/functions/Function1; public fun ()V @@ -161,6 +166,10 @@ public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ public static final field INSTANCE Lcom/squareup/workflow1/PropsUpdated; } +public final class com/squareup/workflow1/RuntimeConfigKt { + public static final fun shouldDeferFirstAction (Ljava/util/Set;)Z +} + public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; @@ -325,6 +334,7 @@ public abstract class com/squareup/workflow1/WorkflowAction { public fun ()V public abstract fun apply (Lcom/squareup/workflow1/WorkflowAction$Updater;)V public fun getDebuggingName ()Ljava/lang/String; + public fun isDeferrable ()Z public fun toString ()Ljava/lang/String; } @@ -418,8 +428,10 @@ public final class com/squareup/workflow1/Workflows { public static final fun action (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; public static final fun action (Lcom/squareup/workflow1/StatelessWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; public static final fun action (Lcom/squareup/workflow1/StatelessWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; - public static final fun action (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; - public static final fun action (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; + public static final fun action (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; + public static final fun action (Lkotlin/jvm/functions/Function0;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; + public static synthetic fun action$default (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/WorkflowAction; + public static synthetic fun action$default (Lkotlin/jvm/functions/Function0;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/WorkflowAction; public static final fun applyTo (Lcom/squareup/workflow1/WorkflowAction;Ljava/lang/Object;Ljava/lang/Object;)Lkotlin/Pair; public static final fun collectToSink (Lkotlinx/coroutines/flow/Flow;Lcom/squareup/workflow1/Sink;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun contraMap (Lcom/squareup/workflow1/Sink;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/Sink; diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt index 240699c8fa..d30b85c461 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt @@ -12,7 +12,16 @@ internal fun BaseRenderContext.eventHandler0( remember: Boolean, update: Updater.() -> Unit ): () -> Unit { - val handler = { actionSink.send(action("eH: $name", update)) } + val handler = { + actionSink.send( + action( + name = "eH: $name", + // Event handlers are *never* deferrable since they respond to UI input. + isDeferrable = false, + apply = update, + ) + ) + } return if (remember) { val box = remember(name) { HandlerBox0() } box.handler = handler @@ -34,7 +43,14 @@ internal inline fun BaseRenderContext.eventHa remember: Boolean, noinline update: Updater.(EventT) -> Unit ): (EventT) -> Unit { - val handler = { e: EventT -> actionSink.send(action("eH: $name") { update(e) }) } + val handler = { e: EventT -> + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e) } + ) + } return if (remember) { val box = remember(name, typeOf()) { HandlerBox1() } box.handler = handler @@ -56,7 +72,14 @@ internal inline fun BaseRenderContext remember: Boolean, noinline update: Updater.(E1, E2) -> Unit ): (E1, E2) -> Unit { - val handler = { e1: E1, e2: E2 -> actionSink.send(action("eH: $name") { update(e1, e2) }) } + val handler = { e1: E1, e2: E2 -> + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2) } + ) + } return if (remember) { val box = remember(name, typeOf(), typeOf()) { HandlerBox2() } box.handler = handler @@ -86,7 +109,14 @@ internal inline fun < noinline update: Updater.(E1, E2, E3) -> Unit ): (E1, E2, E3) -> Unit { val handler = - { e1: E1, e2: E2, e3: E3 -> actionSink.send(action("eH: $name") { update(e1, e2, e3) }) } + { e1: E1, e2: E2, e3: E3 -> + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3) } + ) + } return if (remember) { val box = remember(name, typeOf(), typeOf(), typeOf()) { HandlerBox3() } @@ -118,7 +148,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4) -> Unit ): (E1, E2, E3, E4) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4) } + ) } return if (remember) { val box = remember( @@ -158,7 +193,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4, E5) -> Unit ): (E1, E2, E3, E4, E5) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5) } + ) } return if (remember) { val box = remember( @@ -200,7 +240,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4, E5, E6) -> Unit ): (E1, E2, E3, E4, E5, E6) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5, e6) } + ) } return if (remember) { val box = remember( @@ -244,7 +289,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7) -> Unit ): (E1, E2, E3, E4, E5, E6, E7) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5, e6, e7) } + ) } return if (remember) { val box = remember( @@ -290,7 +340,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7, E8) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5, e6, e7, e8) } + ) } return if (remember) { val box = remember( @@ -338,7 +393,12 @@ internal inline fun < noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) } + ) } return if (remember) { val box = remember( @@ -389,7 +449,12 @@ internal inline fun < ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit { val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9, e10: E10 -> - actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) }) + actionSink.send( + action( + name = "eH: $name", + isDeferrable = false, + ) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) } + ) } return if (remember) { val box = remember( 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 5c31f2ac15..526530a64c 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -19,6 +19,16 @@ public annotation class WorkflowExperimentalRuntime public typealias RuntimeConfig = Set +/** + * Whether or not we have an optimization enabled that should cause us to consider 'deferring' + * the application of the first action received after resuming from suspension in the runtime + * loop. We will only actually defer if [WorkflowAction.isDeferrable] is true for that action. + */ +@WorkflowExperimentalRuntime +public fun RuntimeConfig.shouldDeferFirstAction(): Boolean { + return contains(RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS) +} + /** * A specification of the possible Workflow Runtime options. */ diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt index aa1cc35dd5..d78ac8592a 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt @@ -99,6 +99,9 @@ internal suspend fun < ) { suspendCancellableCoroutine { continuation -> val resumingAction = object : WorkflowAction() { + override val isDeferrable: Boolean + get() = action.isDeferrable + // Pipe through debugging name to the original action. override val debuggingName: String get() = action.debuggingName diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt index 64c2cd0122..3cdf84d3cd 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt @@ -59,9 +59,10 @@ internal class WorkerWorkflow( renderState: Int, context: RenderContext, Int, OutputT> ) { + val localKey = renderState.toString() // Scope the side effect coroutine to the state value, so the worker will be re-started when // it changes (such that doesSameWorkAs returns false above). - context.runningSideEffect(renderState.toString()) { + context.runningSideEffect(localKey) { runWorker(renderProps, key, context.actionSink) } } @@ -97,6 +98,11 @@ private class EmitWorkerOutputAction( override val debuggingName: String = "EmitWorkerOutputAction(worker=$worker, key=$renderKey)" + /** + * All actions from workers are deferrable! + */ + override val isDeferrable: Boolean = true + override fun Updater.apply() { setOutput(output) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt index f2cbd04699..a9baf57d56 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt @@ -3,7 +3,7 @@ package com.squareup.workflow1 -import com.squareup.workflow1.WorkflowAction.Companion.toString +import kotlinx.coroutines.Deferred import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName import kotlin.jvm.JvmOverloads @@ -42,6 +42,18 @@ public abstract class WorkflowAction { */ public open val debuggingName: String = commonUniqueClassName(this::class) + /** + * Whether or not we can wait for one extra dispatch before handling this action. This should + * *only* ever be true for actions that respond to asynchronous events like data loading. + * This should *never* be true for anything respond to UI input. + * + * Note that we *do not* mean deferred to some unknown time in the future. Functionally, this + * means that we can [kotlinx.coroutines.yield] whatever thread we are on once before running. + * Currently this only takes effect when certain optimizations are enabled, like + * [RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS]. + */ + public open val isDeferrable: Boolean = false + /** * The context for calls to [WorkflowAction.apply]. Allows the action to read and change the * [state], and to emit an [output][setOutput] value. @@ -102,6 +114,7 @@ public abstract class WorkflowAction { * of this function directly, to avoid repeating its parameter types. * * @param name A string describing the update for debugging. + * @param isDeferrable see [WorkflowAction.isDeferrable]. * @param apply Function that defines the workflow update. * * @see StatelessWorkflow.action @@ -109,8 +122,9 @@ public abstract class WorkflowAction { */ public fun action( name: String, + isDeferrable: Boolean = false, apply: WorkflowAction.Updater.() -> Unit -): WorkflowAction = action({ name }, apply) +): WorkflowAction = action({ name }, isDeferrable, apply) /** * Creates a [WorkflowAction] from the [apply] lambda. @@ -127,8 +141,11 @@ public fun action( */ public fun action( name: () -> String, + isDeferrable: Boolean = false, apply: WorkflowAction.Updater.() -> Unit ): WorkflowAction = object : WorkflowAction() { + override val isDeferrable: Boolean = isDeferrable + override val debuggingName: String get() = name() @@ -180,6 +197,10 @@ public object PropsUpdated : ActionProcessingResult public object ActionsExhausted : ActionProcessingResult +public class DeferredActionToBeApplied( + public val applyAction: Deferred +) : ActionProcessingResult + /** * Result of applying an action. * diff --git a/workflow-runtime-android/README.md b/workflow-runtime-android/README.md new file mode 100644 index 0000000000..0631e0aed4 --- /dev/null +++ b/workflow-runtime-android/README.md @@ -0,0 +1,4 @@ +# Module Workflow Runtime Android + +This module is an Android library that is used to test the Workflow Runtime with Android specific +coroutine dispatchers. These are headless android-tests that run on device without UI. diff --git a/workflow-runtime-android/api/workflow-runtime-android.api b/workflow-runtime-android/api/workflow-runtime-android.api new file mode 100644 index 0000000000..e69de29bb2 diff --git a/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts new file mode 100644 index 0000000000..d99bbbee87 --- /dev/null +++ b/workflow-runtime-android/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("android-defaults") + id("android-ui-tests") +} + +android { + namespace = "com.squareup.workflow1" + testNamespace = "$namespace.test" +} + +dependencies { + api(project(":workflow-runtime")) + implementation(project(":workflow-core")) + + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.truth) + androidTestImplementation(libs.kotlin.test.core) + androidTestImplementation(libs.kotlin.test.jdk) + androidTestImplementation(libs.kotlinx.coroutines.android) + androidTestImplementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.truth) +} diff --git a/workflow-runtime-android/gradle.properties b/workflow-runtime-android/gradle.properties new file mode 100644 index 0000000000..5f09c5c151 --- /dev/null +++ b/workflow-runtime-android/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=workflow-runtime-android +POM_NAME=Workflow Runtime Android +POM_PACKAGING=aar diff --git a/workflow-runtime-android/src/androidTest/AndroidManifest.xml b/workflow-runtime-android/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..1258204722 --- /dev/null +++ b/workflow-runtime-android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt new file mode 100644 index 0000000000..11707c82e2 --- /dev/null +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt @@ -0,0 +1,500 @@ +package com.squareup.workflow1 + +import android.os.Handler +import android.os.Looper +import android.os.Message +import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING +import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES +import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS +import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete +import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.concurrent.CountDownLatch +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(WorkflowExperimentalRuntime::class, ExperimentalCoroutinesApi::class) +class AndroidRenderWorkflowInTest { + + @Test + fun conflate_renderings_for_multiple_worker_actions_same_trigger() = + runTest(UnconfinedTestDispatcher()) { + + val trigger = MutableStateFlow("unchanged state") + val emitted = mutableListOf() + var renderingsPassed = 0 + val countInterceptor = object : WorkflowInterceptor { + override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { + if (outcome is RenderPassesComplete<*>) { + renderingsPassed++ + } + } + } + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + worker = trigger.drop(1).asWorker(), + key = "Worker1" + ) { + action("") { + val newState = "$it+u1" + state = newState + setOutput(newState) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow) { childOutput -> + action("childHandler") { + state = childOutput + } + } + runningWorker( + worker = trigger.drop(1).asWorker(), + key = "Worker2" + ) { + action("") { + // Update the state in order to show conflation. + state = "$state+u2" + } + } + runningWorker( + worker = trigger.drop(1).asWorker(), + key = "Worker3" + ) { + action("") { + // Update the state in order to show conflation. + state = "$state+u3" + } + } + runningWorker( + worker = trigger.drop(1).asWorker(), + key = "Worker4" + ) { + action("") { + // Update the state in order to show conflation. + state = "$state+u4" + // Output only on the last one! + setOutput(state) + } + } + renderState + } + ) + val props = MutableStateFlow(Unit) + // Render this on the Main.immediate dispatcher from Android. + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + Dispatchers.Main.immediate, + props = props, + runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), + workflowTracer = null, + interceptors = listOf(countInterceptor) + ) { } + + val renderedMutex = Mutex(locked = true) + + val collectionJob = launch(context = Dispatchers.Main.immediate) { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + emitted += it.rendering + println("SAE: ${it.rendering}") + if (it.rendering == "state change+u1+u2+u3+u4") { + renderedMutex.unlock() + } + } + } + + trigger.value = "state change" + + renderedMutex.lock() + + collectionJob.cancel() + + // 2 renderings (initial and then the update.) Not *5* renderings. + assertEquals(2, emitted.size, "Expected only 2 emitted renderings when conflating actions.") + assertEquals( + 2, + renderingsPassed, + "Expected only 2 renderings passed to interceptor when conflating actions." + ) + assertEquals("state change+u1+u2+u3+u4", emitted.last()) + } + + @Test + fun conflate_renderings_for_multiple_side_effect_actions_when_deferrable() = + runTest(UnconfinedTestDispatcher()) { + + val trigger = MutableStateFlow("unchanged state") + val emitted = mutableListOf() + var renderingsPassed = 0 + val countInterceptor = object : WorkflowInterceptor { + override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { + if (outcome is RenderPassesComplete<*>) { + renderingsPassed++ + } + } + } + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningSideEffect("childSideEffect") { + trigger.drop(1).collect { + actionSink.send( + action( + name = "handleChildSideEffectAction", + isDeferrable = true, + ) { + val newState = "$it+u1" + state = newState + setOutput(newState) + } + ) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow) { childOutput -> + action("childHandler") { + state = childOutput + } + } + runningSideEffect("parentSideEffect") { + trigger.drop(1).collect { + actionSink.send( + action( + name = "handleParentSideEffectAction", + isDeferrable = true + ) { + state = "$state+u2" + } + ) + } + } + renderState + } + ) + val props = MutableStateFlow(Unit) + // Render this on the Main.immediate dispatcher from Android. + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + Dispatchers.Main.immediate, + props = props, + runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), + workflowTracer = null, + interceptors = listOf(countInterceptor) + ) { } + + val renderedMutex = Mutex(locked = true) + + val collectionJob = launch(context = Dispatchers.Main.immediate) { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + emitted += it.rendering + if (it.rendering == "state change+u1+u2") { + renderedMutex.unlock() + } + } + } + + trigger.value = "state change" + + renderedMutex.lock() + + collectionJob.cancel() + + // 2 renderings (initial and then the update.) Not *3* renderings. + assertEquals(2, emitted.size, "Expected only 2 emitted renderings when conflating actions.") + assertEquals( + 2, + renderingsPassed, + "Expected only 2 renderings passed to interceptor when conflating actions." + ) + assertEquals("state change+u1+u2", emitted.last()) + } + + @Test + fun do_not_conflate_renderings_for_multiple_side_effect_actions_when_NOT_deferrable() = + runTest(UnconfinedTestDispatcher()) { + + val trigger = MutableStateFlow("unchanged state") + val emitted = mutableListOf() + var renderingsPassed = 0 + val countInterceptor = object : WorkflowInterceptor { + override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { + if (outcome is RenderPassesComplete<*>) { + renderingsPassed++ + } + } + } + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningSideEffect("childSideEffect") { + trigger.drop(1).collect { + actionSink.send( + action( + name = "handleChildSideEffectAction", + ) { + val newState = "$it+u1" + state = newState + setOutput(newState) + } + ) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow) { childOutput -> + action("childHandler") { + state = childOutput + } + } + runningSideEffect("parentSideEffect") { + trigger.drop(1).collect { + actionSink.send( + action( + name = "handleParentSideEffectAction", + ) { + state = "$state+u2" + } + ) + } + } + renderState + } + ) + val props = MutableStateFlow(Unit) + // Render this on the Main.immediate dispatcher from Android. + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + Dispatchers.Main.immediate, + props = props, + runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), + workflowTracer = null, + interceptors = listOf(countInterceptor) + ) { } + + val renderedMutex = Mutex(locked = true) + + val collectionJob = launch(context = Dispatchers.Main.immediate) { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + emitted += it.rendering + if (it.rendering == "state change+u1+u2") { + renderedMutex.unlock() + } + } + } + + trigger.value = "state change" + + renderedMutex.lock() + + collectionJob.cancel() + + // 3 renderings! each update separate. + assertEquals(3, emitted.size, "Expected 3 emitted renderings when conflating actions.") + assertEquals( + 3, + renderingsPassed, + "Expected 3 renderings passed to interceptor when conflating actions." + ) + assertEquals("state change+u1+u2", emitted.last()) + } + + private val runtimes = setOf( + RuntimeConfigOptions.RENDER_PER_ACTION, + setOf(RENDER_ONLY_WHEN_STATE_CHANGES), + setOf(CONFLATE_STALE_RENDERINGS), + setOf(STABLE_EVENT_HANDLERS), + setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES), + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), + setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES, STABLE_EVENT_HANDLERS), + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING, STABLE_EVENT_HANDLERS), + setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), + setOf( + CONFLATE_STALE_RENDERINGS, + RENDER_ONLY_WHEN_STATE_CHANGES, + PARTIAL_TREE_RENDERING, + STABLE_EVENT_HANDLERS + ), + ) + + private class SimpleScreen( + val name: String = "Empty", + val callback: () -> Unit, + ) + + @Test + fun all_runtimes_handle_rendering_events_in_one_message_from_callback() { + // Main thread handler. + val handler = Handler(Looper.getMainLooper()) + + runtimes.forEach { runtimeConfig -> + runTest(UnconfinedTestDispatcher()) { + + var nextMessageRan = false + val theNextMessage = Message.obtain(handler) { + nextMessageRan = true + } + val countDownLatch = CountDownLatch(1) + + val workflow = Workflow.stateful( + initialState = "neverends", + render = { renderState -> + SimpleScreen( + name = renderState, + callback = { + actionSink.send( + action( + name = "handleInput" + ) { + state = "$state+$state" + } + ) + // If we do not end the test within 1 main thread message we'll blow up. + assertTrue( + handler.sendMessage(theNextMessage), + message = "Could not send to handler. This test does not work without that." + ) + } + ) + } + ) + + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + Dispatchers.Main.immediate, + props = MutableStateFlow(Unit).asStateFlow(), + runtimeConfig = runtimeConfig, + workflowTracer = null, + interceptors = emptyList() + ) {} + + val collectionJob = launch(context = Dispatchers.Main.immediate) { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + if (it.rendering.name == "neverends+neverends") { + // The rendering we were looking for! + assertFalse(nextMessageRan, "The sent message ran :(.") + countDownLatch.countDown() + } else { + it.rendering.callback() + } + } + } + + countDownLatch.await() + collectionJob.cancel() + } + } + } + + @Test + fun all_runtimes_handle_deferrable_actions_in_one_message_from_action_applied() { + // Main thread handler. + val handler = Handler(Looper.getMainLooper()) + + runtimes.forEach { runtimeConfig -> + runTest(UnconfinedTestDispatcher()) { + + val trigger = MutableStateFlow("unchanged state") + + var nextMessageRan = false + val theNextMessage = Message.obtain(handler) { + nextMessageRan = true + } + val countDownLatch = CountDownLatch(1) + + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningSideEffect("only1") { + trigger.drop(1).collect { + actionSink.send( + action( + name = "triggerCollect", + isDeferrable = true + ) { + state = it + // If we do not end the test within 1 main thread message we'll blow up. + assertTrue( + handler.sendMessage(theNextMessage), + message = "Could not send to handler. This test does not work without that." + ) + } + ) + } + } + renderState + } + ) + + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + Dispatchers.Main.immediate, + props = MutableStateFlow(Unit).asStateFlow(), + runtimeConfig = runtimeConfig, + workflowTracer = null, + interceptors = emptyList() + ) {} + + val collectionJob = launch(context = Dispatchers.Main.immediate) { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + if (it.rendering == "changed state") { + // The rendering we were looking for! + assertFalse(nextMessageRan, "The sent message ran :(.") + countDownLatch.countDown() + } + } + } + + trigger.emit("changed state") + + countDownLatch.await() + collectionJob.cancel() + } + } + } +} 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 78a25f097f..075b7d155b 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.yield /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -178,10 +179,17 @@ public fun renderWorkflowIn( scope.launch { outer@ while (isActive) { - // It might look weird to start by processing an action before getting the rendering below, + // It might look weird to start by waiting for an action before getting the rendering below, // but remember the first render pass already occurred above, before this coroutine was even // launched. - var actionResult: ActionProcessingResult = runner.processAction() + var actionResult: ActionProcessingResult = runner.waitForAction() + + if (actionResult is DeferredActionToBeApplied) { + // If we are deferring the first action, yield first to let any other actions queue up, so + // we can process as many as possible below. + yield() + actionResult = actionResult.applyAction.await() + } if (shouldShortCircuitForUnchangedState(actionResult)) { chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped()) @@ -200,8 +208,9 @@ public fun renderWorkflowIn( var conflationHasChangedState = false conflate@ while (isActive && actionResult is ActionApplied<*> && actionResult.output == null) { conflationHasChangedState = conflationHasChangedState || actionResult.stateChanged - // We may have more actions we can process, this rendering could be stale. - actionResult = runner.processAction(waitForAnAction = false) + + // We may have more actions we can apply, this rendering could be stale. + actionResult = runner.applyNextAvailableAction() // If no actions processed, then no new rendering needed. Pass on to UI. if (actionResult == ActionsExhausted) break@conflate @@ -222,7 +231,7 @@ public fun renderWorkflowIn( continue@outer } - // Make sure the runtime has not been cancelled from runner.processAction() + // Make sure the runtime has not been cancelled. if (!isActive) return@launch nextRenderAndSnapshot = runner.nextRendering() diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 09fb7608a3..688e96fe73 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.TreeSnapshot @@ -146,19 +147,26 @@ internal class SubtreeManager( } /** - * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance + * Uses [selector] to invoke [WorkflowNode.selectNextAction] for every running child workflow this instance * is managing. * - * @return [Boolean] whether or not the children action queues are empty. */ - fun onNextChildAction(selector: SelectBuilder): Boolean { - var empty = true + fun selectNextChildAction( + selector: SelectBuilder, + ) { children.forEachActive { child -> - // Do this separately so the compiler doesn't avoid it if empty is already false. - val childEmpty = child.workflowNode.onNextAction(selector) - empty = childEmpty && empty + child.workflowNode.selectNextAction(selector) } - return empty + } + + fun applyNextAvailableChildAction(): ActionProcessingResult { + children.forEachActive { child -> + val result = child.workflowNode.applyNextAvailableAction() + if (result != ActionsExhausted) { + return result + } + } + return ActionsExhausted } fun createChildSnapshots(): Map { 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 04c680fb20..ded8d82165 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 @@ -2,6 +2,8 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted +import com.squareup.workflow1.DeferredActionToBeApplied import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.NullableInitBox import com.squareup.workflow1.RenderContext @@ -22,14 +24,14 @@ 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.shouldDeferFirstAction import com.squareup.workflow1.trace import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.LAZY -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -208,29 +210,44 @@ internal class WorkflowNode( * * It is an error to call this method after calling [cancel]. * - * @return [Boolean] whether or not the queues were empty for this node and its children at the - * time of suspending. */ - @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) - fun onNextAction(selector: SelectBuilder): Boolean { + fun selectNextAction( + selector: SelectBuilder, + ) { // Listen for any child workflow updates. - var empty = subtreeManager.onNextChildAction(selector) - - empty = empty && (eventActionsChannel.isEmpty || eventActionsChannel.isClosedForReceive) + subtreeManager.selectNextChildAction(selector) // Listen for any events. with(selector) { eventActionsChannel.onReceive { action -> + if (runtimeConfig.shouldDeferFirstAction() && action.isDeferrable) { + return@onReceive DeferredActionToBeApplied( + applyAction = async { + applyAction(action) + } + ) + } + return@onReceive applyAction(action) } } - return empty + } + + fun applyNextAvailableAction(): ActionProcessingResult { + val result = subtreeManager.applyNextAvailableChildAction() + + if (result == ActionsExhausted) { + return eventActionsChannel.tryReceive().getOrNull()?.let { action -> + applyAction(action) + } ?: ActionsExhausted + } + return result } /** * Cancels this state machine host, and any coroutines started as children of it. * - * This must be called when the caller will no longer call [onNextAction]. It is an error to call [onNextAction] + * This must be called when the caller will no longer call [selectNextAction]. It is an error to call [selectNextAction] * after calling this method. */ fun cancel(cause: CancellationException? = null) { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 9eb66bb1ba..f0d5a58b1e 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -1,11 +1,9 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionProcessingResult -import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.PropsUpdated import com.squareup.workflow1.RenderingAndSnapshot import com.squareup.workflow1.RuntimeConfig -import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowExperimentalRuntime @@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.produceIn import kotlinx.coroutines.selects.SelectBuilder -import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select @OptIn(ExperimentalCoroutinesApi::class) @@ -65,8 +62,8 @@ internal class WorkflowRunner( /** * Perform a render pass and a snapshot pass and return the results. * - * This method must be called before the first call to [processAction], and must be called again - * between every subsequent call to [processAction]. + * This method must be called before the first call to [waitForAction], and must be called again + * between every subsequent call to [waitForAction]. */ fun nextRendering(): RenderingAndSnapshot { return interceptor.onRenderAndSnapshot(currentProps, { props -> @@ -77,30 +74,26 @@ internal class WorkflowRunner( } /** - * Process the first action from anywhere in the Workflow tree, or process the updated props. + * Suspends waiting to process the next action from anywhere in the Workflow tree, or process + * the updated props. * * [select] is used which suspends on multiple coroutines, executing the first to be scheduled * and resume (breaking ties with order of declaration). Guarantees only continuing on the winning * coroutine and no others. */ - @OptIn(WorkflowExperimentalRuntime::class) - suspend fun processAction(waitForAnAction: Boolean = true): ActionProcessingResult { - // If waitForAction is true we block and wait until there is an action to process. + suspend fun waitForAction(): ActionProcessingResult { + // If firstAction is true we block and wait until there is an action to process. return select { onPropsUpdated() // Have the workflow tree build the select to wait for an event/output from Worker. - val empty = rootNode.onNextAction(this) - if (!waitForAnAction && runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && empty) { - // With CONFLATE_STALE_RENDERINGS if there are no queued actions and we are not - // waiting for one, then return ActionsExhausted and pass the rendering on. - onTimeout(timeMillis = 0) { - // This will select synchronously since time is 0. - ActionsExhausted - } - } + rootNode.selectNextAction(this) } } + fun applyNextAvailableAction(): ActionProcessingResult { + return rootNode.applyNextAvailableAction() + } + @OptIn(DelicateCoroutinesApi::class) private fun SelectBuilder.onPropsUpdated() { // Stop trying to read from the inputs channel after it's closed. diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt index 909bfff5c7..1d68d4f6f5 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt @@ -305,7 +305,7 @@ internal class SubtreeManagerTest { @Suppress("UNCHECKED_CAST") private suspend fun SubtreeManager.applyNextAction() = select { - onNextChildAction(this) + selectNextChildAction(this) } as ActionApplied?> private fun subtreeManagerForTest( diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt index 616630284a..01fa388a72 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt @@ -190,7 +190,7 @@ internal class WorkflowNodeTest { runTest { val result = withTimeout(10) { select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied } assertEquals("applyActionOutput:event", result.output!!.value) @@ -236,7 +236,7 @@ internal class WorkflowNodeTest { val result = withTimeout(10) { List(2) { select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied } } @@ -340,7 +340,7 @@ internal class WorkflowNodeTest { // Result should be available instantly, any delay at all indicates something is broken. val result = withTimeout(1) { select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied } assertEquals("result", result.output!!.value) @@ -1198,7 +1198,7 @@ internal class WorkflowNodeTest { sink.send("hello") val result = select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied assertNull(result.output) assertTrue(result.stateChanged) @@ -1227,7 +1227,7 @@ internal class WorkflowNodeTest { runTest { val result = select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied assertEquals("output:hello", result.output!!.value) assertFalse(result.stateChanged) @@ -1252,7 +1252,7 @@ internal class WorkflowNodeTest { runTest { val result = select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied assertNull(result.output!!.value) assertFalse(result.stateChanged) @@ -1279,7 +1279,7 @@ internal class WorkflowNodeTest { node.render(workflow.asStatefulWorkflow(), Unit) select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied val state = node.render(workflow.asStatefulWorkflow(), Unit) @@ -1306,7 +1306,7 @@ internal class WorkflowNodeTest { runTest { val result = select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied assertEquals("output:child:hello", result.output!!.value) } @@ -1330,7 +1330,7 @@ internal class WorkflowNodeTest { runTest { val result = select { - node.onNextAction(this) + node.selectNextAction(this) } as ActionApplied assertNull(result.output!!.value) } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt index 18e657ce44..90fec2e94b 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.DeferredActionToBeApplied import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions @@ -86,7 +87,7 @@ internal class WorkflowRunnerTest { } } - @Test fun initial_processActions_does_not_handle_initial_props() { + @Test fun initial_waitForActions_does_not_handle_initial_props() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -102,14 +103,14 @@ internal class WorkflowRunnerTest { ) runner.nextRendering() - val outputDeferred = scope.async { runner.processAction() } + val outputDeferred = scope.async { runner.waitForAction() } scope.runCurrent() assertTrue(outputDeferred.isActive) } } - @Test fun initial_processActions_handles_props_changed_after_initialization() { + @Test fun initial_waitForActions_handles_props_changed_after_initialization() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -131,7 +132,7 @@ internal class WorkflowRunnerTest { // Get the runner into the state where it's waiting for a props update. val initialRendering = runner.nextRendering().rendering assertEquals("initial", initialRendering) - val output = scope.async { runner.processAction() } + val output = scope.async { runner.waitForAction() } assertTrue(output.isActive) // Resume the dispatcher to start the coroutines and process the new props value. @@ -146,7 +147,7 @@ internal class WorkflowRunnerTest { } } - @Test fun processActions_handles_workflow_update() { + @Test fun waitForActions_handles_workflow_update() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -179,7 +180,7 @@ internal class WorkflowRunnerTest { } } - @Test fun processActions_handles_concurrent_props_change_and_workflow_update() { + @Test fun waitForActions_handles_concurrent_props_change_and_workflow_update() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -219,7 +220,7 @@ internal class WorkflowRunnerTest { } } - @Test fun cancelRuntime_does_not_interrupt_processActions() { + @Test fun cancelRuntime_does_not_interrupt_waitForActions() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -229,7 +230,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val output = scope.async { runner.processAction() } + val output = scope.async { runner.waitForAction() } scope.runCurrent() assertTrue(output.isActive) @@ -272,7 +273,7 @@ internal class WorkflowRunnerTest { } } - @Test fun cancelling_scope_interrupts_processActions() { + @Test fun cancelling_scope_interrupts_waitForActions() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -283,7 +284,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val actionResult = scope.async { runner.processAction() } + val actionResult = scope.async { runner.waitForAction() } scope.runCurrent() assertTrue(actionResult.isActive) @@ -314,7 +315,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val actionResult = scope.async { runner.processAction() } + val actionResult = scope.async { runner.waitForAction() } scope.runCurrent() assertTrue(actionResult.isActive) assertNull(cancellationException) @@ -330,10 +331,17 @@ internal class WorkflowRunnerTest { @Suppress("UNCHECKED_CAST") private fun WorkflowRunner<*, T, *>.runTillNextActionResult(): ActionApplied? = scope.run { - val firstOutputDeferred = async { processAction() } + val firstOutputDeferred = async { waitForAction() } runCurrent() + val actionResult = firstOutputDeferred.getCompleted() // If it is [ PropsUpdated] or any other ActionProcessingResult, will return as null. - firstOutputDeferred.getCompleted() as? ActionApplied + val finalActionResult = if (actionResult is DeferredActionToBeApplied) { + runCurrent() + actionResult.applyAction.getCompleted() as? ActionApplied + } else { + actionResult as? ActionApplied + } + return@run finalActionResult } @Suppress("TestFunctionName") From 9372c896466b5a9659149b05ce7fc38d166e2a2d Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Tue, 17 Jun 2025 11:58:22 -0400 Subject: [PATCH 2/2] 1311: Use compose dispatcher; Move Android Render Workflow --- .../poetry/PerformancePoetryActivity.kt | 2 +- gradle/libs.versions.toml | 2 + .../HelloBindingActivity.kt | 2 +- .../HelloComposeWorkflowActivity.kt | 2 +- .../InlineRenderingActivity.kt | 2 +- .../NestedRenderingsActivity.kt | 2 +- .../sample/poetryapp/PoetryActivity.kt | 2 +- .../squareup/sample/ravenapp/RavenActivity.kt | 2 +- .../HelloBackButtonActivity.kt | 2 +- .../sample/dungeon/TimeMachineModel.kt | 2 +- .../HelloWorkflowFragment.kt | 2 +- .../helloworkflow/HelloWorkflowActivity.kt | 2 +- .../nestedoverlays/NestedOverlaysActivity.kt | 2 +- .../stubvisibility/StubVisibilityActivity.kt | 2 +- .../sample/mainactivity/TicTacToeModel.kt | 2 +- .../com/squareup/sample/todo/ToDoActivity.kt | 2 +- workflow-core/api/workflow-core.api | 16 +- .../com/squareup/workflow1/HandlerBox.kt | 12 - .../com/squareup/workflow1/RuntimeConfig.kt | 10 +- .../kotlin/com/squareup/workflow1/Sink.kt | 3 - .../com/squareup/workflow1/WorkerWorkflow.kt | 5 - .../com/squareup/workflow1/WorkflowAction.kt | 24 +- .../api/workflow-runtime-android.api | 15 + workflow-runtime-android/build.gradle.kts | 8 + ...AndroidDispatchersRenderWorkflowInTest.kt} | 257 +++++++----------- .../android}/AndroidRenderWorkflowInTest.kt | 7 +- .../android}/AndroidRenderWorkflow.kt | 35 +-- .../workflow1/android}/PickledTreesnapshot.kt | 2 +- .../workflow1/android}/TreeSnapshotSaver.kt | 4 +- .../workflow1/android/WorkflowFrameClock.kt | 20 ++ workflow-runtime/api/workflow-runtime.api | 13 +- .../com/squareup/workflow1/RenderWorkflow.kt | 34 ++- .../squareup/workflow1/WorkflowFrameClock.kt | 23 ++ .../workflow1/internal/WorkflowNode.kt | 17 +- .../workflow1/internal/WorkflowRunner.kt | 1 - .../workflow1/internal/WorkflowRunnerTest.kt | 11 +- .../workflow1/internal}/SystemUtils.kt | 0 .../workflow1/internal}/SystemUtils.kt | 0 workflow-ui/core-android/api/core-android.api | 10 - workflow-ui/core-android/build.gradle.kts | 1 + .../dependencies/releaseRuntimeClasspath.txt | 32 ++- .../dependencies/releaseRuntimeClasspath.txt | 32 ++- 42 files changed, 311 insertions(+), 313 deletions(-) rename workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/{AndroidRenderWorkflowInTest.kt => android/AndroidDispatchersRenderWorkflowInTest.kt} (62%) rename {workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui => workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android}/AndroidRenderWorkflowInTest.kt (91%) rename {workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui => workflow-runtime-android/src/main/java/com/squareup/workflow1/android}/AndroidRenderWorkflow.kt (95%) rename {workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui => workflow-runtime-android/src/main/java/com/squareup/workflow1/android}/PickledTreesnapshot.kt (95%) rename {workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui => workflow-runtime-android/src/main/java/com/squareup/workflow1/android}/TreeSnapshotSaver.kt (94%) create mode 100644 workflow-runtime-android/src/main/java/com/squareup/workflow1/android/WorkflowFrameClock.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowFrameClock.kt rename workflow-runtime/src/iosMain/kotlin/{com.squareup.workflow1.internal => com/squareup/workflow1/internal}/SystemUtils.kt (100%) rename workflow-runtime/src/jsMain/kotlin/{com.squareup.workflow1.internal => com/squareup/workflow1/internal}/SystemUtils.kt (100%) diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt index ece4623963..e52512c534 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt @@ -22,10 +22,10 @@ import com.squareup.sample.poetry.model.Poem import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions.Companion.RENDER_PER_ACTION import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 530df49c74..bc01c24e59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,6 +92,7 @@ timber = "5.0.1" truth = "1.4.4" turbine = "1.0.0" vanniktech-publish = "0.32.0" +uiAndroidVersion = "1.8.2" [plugins] @@ -135,6 +136,7 @@ androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-android = { module = "androidx.compose.ui:ui-android" } androidx-compose-ui-geometry = { module = "androidx.compose.ui:ui-geometry" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt index 69efe093cb..9d368ad56b 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen @@ -17,7 +18,6 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt index 5f6c7f537b..2c5ea4f0bf 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt @@ -9,12 +9,12 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt index 8047cf7a47..47222c60cc 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt @@ -9,12 +9,12 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow 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 3873850063..c39c42c787 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,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen @@ -18,7 +19,6 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow 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 6d4d8cd4d6..f0b555883c 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 @@ -13,10 +13,10 @@ import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.RealPoemsBrowserWorkflow import com.squareup.sample.poetry.model.Poem import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.Flow 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 e91d38043f..e6900be1ae 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 @@ -13,10 +13,10 @@ import com.squareup.sample.container.SampleContainers import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.model.Raven import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job 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 50cb56c6e1..76ef950524 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 @@ -11,10 +11,10 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import com.squareup.sample.container.SampleContainers import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job 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 7ec6d57175..ed9865f8ca 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 @@ -6,10 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.flow.StateFlow import java.io.File import kotlin.time.ExperimentalTime diff --git a/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt b/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt index 5bca0cdee3..20512befca 100644 --- a/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt +++ b/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt @@ -11,9 +11,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.flow.StateFlow class HelloWorkflowFragment : Fragment() { diff --git a/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt b/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt index 6a6a702b82..fa6623e04e 100644 --- a/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt +++ b/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt @@ -9,8 +9,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt index 9c8c16ac1f..9442ffff14 100644 --- a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt +++ b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt @@ -9,9 +9,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt b/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt index 50b6fe7aa4..aa8a4fe52a 100644 --- a/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt +++ b/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt @@ -9,9 +9,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow 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 3d4ae097c2..8fe7014641 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 @@ -9,10 +9,10 @@ import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.squareup.sample.mainworkflow.TicTacToeWorkflow import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import java.io.File diff --git a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt index c957d2674c..ff0e88bda9 100644 --- a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt +++ b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt @@ -10,11 +10,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.sample.container.overviewdetail.OverviewDetailContainer import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 53330a7111..567217d4f0 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -35,11 +35,6 @@ public final class com/squareup/workflow1/BaseRenderContext$DefaultImpls { public static synthetic fun renderChild$default (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } -public final class com/squareup/workflow1/DeferredActionToBeApplied : com/squareup/workflow1/ActionProcessingResult { - public fun (Lkotlinx/coroutines/Deferred;)V - public final fun getApplyAction ()Lkotlinx/coroutines/Deferred; -} - public final class com/squareup/workflow1/HandlerBox1 { public field handler Lkotlin/jvm/functions/Function1; public fun ()V @@ -166,10 +161,6 @@ public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ public static final field INSTANCE Lcom/squareup/workflow1/PropsUpdated; } -public final class com/squareup/workflow1/RuntimeConfigKt { - public static final fun shouldDeferFirstAction (Ljava/util/Set;)Z -} - public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; @@ -334,7 +325,6 @@ public abstract class com/squareup/workflow1/WorkflowAction { public fun ()V public abstract fun apply (Lcom/squareup/workflow1/WorkflowAction$Updater;)V public fun getDebuggingName ()Ljava/lang/String; - public fun isDeferrable ()Z public fun toString ()Ljava/lang/String; } @@ -428,10 +418,8 @@ public final class com/squareup/workflow1/Workflows { public static final fun action (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; public static final fun action (Lcom/squareup/workflow1/StatelessWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; public static final fun action (Lcom/squareup/workflow1/StatelessWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; - public static final fun action (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; - public static final fun action (Lkotlin/jvm/functions/Function0;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; - public static synthetic fun action$default (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/WorkflowAction; - public static synthetic fun action$default (Lkotlin/jvm/functions/Function0;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/WorkflowAction; + public static final fun action (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; + public static final fun action (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; public static final fun applyTo (Lcom/squareup/workflow1/WorkflowAction;Ljava/lang/Object;Ljava/lang/Object;)Lkotlin/Pair; public static final fun collectToSink (Lkotlinx/coroutines/flow/Flow;Lcom/squareup/workflow1/Sink;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun contraMap (Lcom/squareup/workflow1/Sink;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/Sink; diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt index d30b85c461..067dcfeb2b 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt @@ -16,8 +16,6 @@ internal fun BaseRenderContext.eventHandler0( actionSink.send( action( name = "eH: $name", - // Event handlers are *never* deferrable since they respond to UI input. - isDeferrable = false, apply = update, ) ) @@ -47,7 +45,6 @@ internal inline fun BaseRenderContext.eventHa actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e) } ) } @@ -76,7 +73,6 @@ internal inline fun BaseRenderContext actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2) } ) } @@ -113,7 +109,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3) } ) } @@ -151,7 +146,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4) } ) } @@ -196,7 +190,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5) } ) } @@ -243,7 +236,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5, e6) } ) } @@ -292,7 +284,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5, e6, e7) } ) } @@ -343,7 +334,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5, e6, e7, e8) } ) } @@ -396,7 +386,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) } ) } @@ -452,7 +441,6 @@ internal inline fun < actionSink.send( action( name = "eH: $name", - isDeferrable = false, ) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) } ) } 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 526530a64c..f3596935ee 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -22,12 +22,12 @@ public typealias RuntimeConfig = Set /** * Whether or not we have an optimization enabled that should cause us to consider 'deferring' * the application of the first action received after resuming from suspension in the runtime - * loop. We will only actually defer if [WorkflowAction.isDeferrable] is true for that action. + * loop. */ -@WorkflowExperimentalRuntime -public fun RuntimeConfig.shouldDeferFirstAction(): Boolean { - return contains(RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS) -} +// @WorkflowExperimentalRuntime +// public fun RuntimeConfig.shouldDeferFirstAction(): Boolean { +// return contains(RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS) +// } /** * A specification of the possible Workflow Runtime options. diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt index d78ac8592a..aa1cc35dd5 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Sink.kt @@ -99,9 +99,6 @@ internal suspend fun < ) { suspendCancellableCoroutine { continuation -> val resumingAction = object : WorkflowAction() { - override val isDeferrable: Boolean - get() = action.isDeferrable - // Pipe through debugging name to the original action. override val debuggingName: String get() = action.debuggingName diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt index 3cdf84d3cd..7ed0c48309 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt @@ -98,11 +98,6 @@ private class EmitWorkerOutputAction( override val debuggingName: String = "EmitWorkerOutputAction(worker=$worker, key=$renderKey)" - /** - * All actions from workers are deferrable! - */ - override val isDeferrable: Boolean = true - override fun Updater.apply() { setOutput(output) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt index a9baf57d56..490b3850a2 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt @@ -3,7 +3,6 @@ package com.squareup.workflow1 -import kotlinx.coroutines.Deferred import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName import kotlin.jvm.JvmOverloads @@ -42,18 +41,6 @@ public abstract class WorkflowAction { */ public open val debuggingName: String = commonUniqueClassName(this::class) - /** - * Whether or not we can wait for one extra dispatch before handling this action. This should - * *only* ever be true for actions that respond to asynchronous events like data loading. - * This should *never* be true for anything respond to UI input. - * - * Note that we *do not* mean deferred to some unknown time in the future. Functionally, this - * means that we can [kotlinx.coroutines.yield] whatever thread we are on once before running. - * Currently this only takes effect when certain optimizations are enabled, like - * [RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS]. - */ - public open val isDeferrable: Boolean = false - /** * The context for calls to [WorkflowAction.apply]. Allows the action to read and change the * [state], and to emit an [output][setOutput] value. @@ -114,7 +101,6 @@ public abstract class WorkflowAction { * of this function directly, to avoid repeating its parameter types. * * @param name A string describing the update for debugging. - * @param isDeferrable see [WorkflowAction.isDeferrable]. * @param apply Function that defines the workflow update. * * @see StatelessWorkflow.action @@ -122,9 +108,8 @@ public abstract class WorkflowAction { */ public fun action( name: String, - isDeferrable: Boolean = false, apply: WorkflowAction.Updater.() -> Unit -): WorkflowAction = action({ name }, isDeferrable, apply) +): WorkflowAction = action({ name }, apply) /** * Creates a [WorkflowAction] from the [apply] lambda. @@ -141,11 +126,8 @@ public fun action( */ public fun action( name: () -> String, - isDeferrable: Boolean = false, apply: WorkflowAction.Updater.() -> Unit ): WorkflowAction = object : WorkflowAction() { - override val isDeferrable: Boolean = isDeferrable - override val debuggingName: String get() = name() @@ -197,10 +179,6 @@ public object PropsUpdated : ActionProcessingResult public object ActionsExhausted : ActionProcessingResult -public class DeferredActionToBeApplied( - public val applyAction: Deferred -) : ActionProcessingResult - /** * Result of applying an action. * diff --git a/workflow-runtime-android/api/workflow-runtime-android.api b/workflow-runtime-android/api/workflow-runtime-android.api index e69de29bb2..341ac870ad 100644 --- a/workflow-runtime-android/api/workflow-runtime-android.api +++ b/workflow-runtime-android/api/workflow-runtime-android.api @@ -0,0 +1,15 @@ +public final class com/squareup/workflow1/android/AndroidFrameClock : com/squareup/workflow1/WorkflowFrameClock { + public static final field INSTANCE Lcom/squareup/workflow1/android/AndroidFrameClock; + public fun resumeOnFrame (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/android/AndroidRenderWorkflowKt { + public static final fun removeWorkflowState (Landroidx/lifecycle/SavedStateHandle;)V + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; +} + diff --git a/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts index d99bbbee87..16396c561c 100644 --- a/workflow-runtime-android/build.gradle.kts +++ b/workflow-runtime-android/build.gradle.kts @@ -11,9 +11,17 @@ android { } dependencies { + val composeBom = platform(libs.androidx.compose.bom) + api(project(":workflow-runtime")) + api(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(project(":workflow-core")) + implementation(composeBom) + implementation(libs.androidx.compose.ui.android) + androidTestImplementation(libs.androidx.activity.ktx) + androidTestImplementation(libs.androidx.lifecycle.viewmodel.ktx) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.truth) androidTestImplementation(libs.kotlin.test.core) diff --git a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt similarity index 62% rename from workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt rename to workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt index 11707c82e2..300f561c70 100644 --- a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt @@ -1,16 +1,24 @@ -package com.squareup.workflow1 +package com.squareup.workflow1.android -import android.os.Handler -import android.os.Looper -import android.os.Message +import android.view.Choreographer +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.test.platform.app.InstrumentationRegistry +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers +import com.squareup.workflow1.action +import com.squareup.workflow1.asWorker +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.runningWorker +import com.squareup.workflow1.stateful import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,13 +29,11 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test -import java.util.concurrent.CountDownLatch import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue @OptIn(WorkflowExperimentalRuntime::class, ExperimentalCoroutinesApi::class) -class AndroidRenderWorkflowInTest { +class AndroidDispatchersRenderWorkflowInTest { @Test fun conflate_renderings_for_multiple_worker_actions_same_trigger() = @@ -101,11 +107,11 @@ class AndroidRenderWorkflowInTest { } ) val props = MutableStateFlow(Unit) - // Render this on the Main.immediate dispatcher from Android. + // Render this on Compose's AndroidUiDispatcher.Main val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = props, runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), workflowTracer = null, @@ -114,13 +120,13 @@ class AndroidRenderWorkflowInTest { val renderedMutex = Mutex(locked = true) - val collectionJob = launch(context = Dispatchers.Main.immediate) { + val collectionJob = launch { // Collect this unconfined so we can get all the renderings faster than actions can // be processed. renderings.collect { - emitted += it.rendering - println("SAE: ${it.rendering}") - if (it.rendering == "state change+u1+u2+u3+u4") { + emitted += it + println("SAE: $it") + if (it == "state change+u1+u2+u3+u4") { renderedMutex.unlock() } } @@ -143,7 +149,7 @@ class AndroidRenderWorkflowInTest { } @Test - fun conflate_renderings_for_multiple_side_effect_actions_when_deferrable() = + fun conflate_renderings_for_multiple_side_effect_actions() = runTest(UnconfinedTestDispatcher()) { val trigger = MutableStateFlow("unchanged state") @@ -165,7 +171,6 @@ class AndroidRenderWorkflowInTest { actionSink.send( action( name = "handleChildSideEffectAction", - isDeferrable = true, ) { val newState = "$it+u1" state = newState @@ -190,7 +195,6 @@ class AndroidRenderWorkflowInTest { actionSink.send( action( name = "handleParentSideEffectAction", - isDeferrable = true ) { state = "$state+u2" } @@ -205,7 +209,7 @@ class AndroidRenderWorkflowInTest { val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = props, runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), workflowTracer = null, @@ -214,12 +218,12 @@ class AndroidRenderWorkflowInTest { val renderedMutex = Mutex(locked = true) - val collectionJob = launch(context = Dispatchers.Main.immediate) { + val collectionJob = launch { // Collect this unconfined so we can get all the renderings faster than actions can // be processed. renderings.collect { - emitted += it.rendering - if (it.rendering == "state change+u1+u2") { + emitted += it + if (it == "state change+u1+u2") { renderedMutex.unlock() } } @@ -241,103 +245,6 @@ class AndroidRenderWorkflowInTest { assertEquals("state change+u1+u2", emitted.last()) } - @Test - fun do_not_conflate_renderings_for_multiple_side_effect_actions_when_NOT_deferrable() = - runTest(UnconfinedTestDispatcher()) { - - val trigger = MutableStateFlow("unchanged state") - val emitted = mutableListOf() - var renderingsPassed = 0 - val countInterceptor = object : WorkflowInterceptor { - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { - if (outcome is RenderPassesComplete<*>) { - renderingsPassed++ - } - } - } - - val childWorkflow = Workflow.stateful( - initialState = "unchanged state", - render = { renderState -> - runningSideEffect("childSideEffect") { - trigger.drop(1).collect { - actionSink.send( - action( - name = "handleChildSideEffectAction", - ) { - val newState = "$it+u1" - state = newState - setOutput(newState) - } - ) - } - } - renderState - } - ) - val workflow = Workflow.stateful( - initialState = "unchanged state", - render = { renderState -> - renderChild(childWorkflow) { childOutput -> - action("childHandler") { - state = childOutput - } - } - runningSideEffect("parentSideEffect") { - trigger.drop(1).collect { - actionSink.send( - action( - name = "handleParentSideEffectAction", - ) { - state = "$state+u2" - } - ) - } - } - renderState - } - ) - val props = MutableStateFlow(Unit) - // Render this on the Main.immediate dispatcher from Android. - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope + - Dispatchers.Main.immediate, - props = props, - runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), - workflowTracer = null, - interceptors = listOf(countInterceptor) - ) { } - - val renderedMutex = Mutex(locked = true) - - val collectionJob = launch(context = Dispatchers.Main.immediate) { - // Collect this unconfined so we can get all the renderings faster than actions can - // be processed. - renderings.collect { - emitted += it.rendering - if (it.rendering == "state change+u1+u2") { - renderedMutex.unlock() - } - } - } - - trigger.value = "state change" - - renderedMutex.lock() - - collectionJob.cancel() - - // 3 renderings! each update separate. - assertEquals(3, emitted.size, "Expected 3 emitted renderings when conflating actions.") - assertEquals( - 3, - renderingsPassed, - "Expected 3 renderings passed to interceptor when conflating actions." - ) - assertEquals("state change+u1+u2", emitted.last()) - } - private val runtimes = setOf( RuntimeConfigOptions.RENDER_PER_ACTION, setOf(RENDER_ONLY_WHEN_STATE_CHANGES), @@ -362,18 +269,26 @@ class AndroidRenderWorkflowInTest { ) @Test - fun all_runtimes_handle_rendering_events_in_one_message_from_callback() { - // Main thread handler. - val handler = Handler(Looper.getMainLooper()) + fun all_runtimes_handle_rendering_events_before_next_frame() { + + var mainChoreographer: Choreographer? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + mainChoreographer = Choreographer.getInstance() + } + + var theFrameWasRun = false + val frameCallback = Choreographer.FrameCallback { + theFrameWasRun = true + println("SAE: Frame callback run.") + } runtimes.forEach { runtimeConfig -> runTest(UnconfinedTestDispatcher()) { - var nextMessageRan = false - val theNextMessage = Message.obtain(handler) { - nextMessageRan = true - } - val countDownLatch = CountDownLatch(1) + println("SAE: TEST CONFIG: $runtimeConfig") + + theFrameWasRun = false + val mutex = Mutex(locked = true) val workflow = Workflow.stateful( initialState = "neverends", @@ -381,18 +296,17 @@ class AndroidRenderWorkflowInTest { SimpleScreen( name = renderState, callback = { + println("SAE: CALLBACK FIRED") actionSink.send( action( name = "handleInput" ) { + println("SAE: handleInput action applied") state = "$state+$state" } ) - // If we do not end the test within 1 main thread message we'll blow up. - assertTrue( - handler.sendMessage(theNextMessage), - message = "Could not send to handler. This test does not work without that." - ) + mainChoreographer!!.postFrameCallback(frameCallback) + println("SAE: set up frame callback") } ) } @@ -401,67 +315,74 @@ class AndroidRenderWorkflowInTest { val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = MutableStateFlow(Unit).asStateFlow(), runtimeConfig = runtimeConfig, workflowTracer = null, interceptors = emptyList() ) {} - val collectionJob = launch(context = Dispatchers.Main.immediate) { - // Collect this unconfined so we can get all the renderings faster than actions can - // be processed. + val collectionJob = launch { renderings.collect { - if (it.rendering.name == "neverends+neverends") { - // The rendering we were looking for! - assertFalse(nextMessageRan, "The sent message ran :(.") - countDownLatch.countDown() + println("SAE: got rendering: ${it.name}") + if (it.name == "neverends+neverends") { + // The rendering we were looking for after the event! + assertFalse( + theFrameWasRun, + "The callback on this frame was run before we" + + "got our rendering!" + ) + mainChoreographer!!.removeFrameCallback(frameCallback) + mutex.unlock() } else { - it.rendering.callback() + it.callback() } } } - countDownLatch.await() + mutex.lock() collectionJob.cancel() } } } @Test - fun all_runtimes_handle_deferrable_actions_in_one_message_from_action_applied() { - // Main thread handler. - val handler = Handler(Looper.getMainLooper()) + fun all_runtimes_handle_actions_before_the_next_frame() { + var mainChoreographer: Choreographer? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + mainChoreographer = Choreographer.getInstance() + } + + var theFrameWasRun = false + val frameCallback = Choreographer.FrameCallback { + theFrameWasRun = true + println("SAE: Frame callback run.") + } runtimes.forEach { runtimeConfig -> runTest(UnconfinedTestDispatcher()) { - val trigger = MutableStateFlow("unchanged state") + println("SAE: Running test with config: $runtimeConfig") - var nextMessageRan = false - val theNextMessage = Message.obtain(handler) { - nextMessageRan = true - } - val countDownLatch = CountDownLatch(1) + val trigger = MutableStateFlow("unchanged state") + val mutex = Mutex(locked = true) + theFrameWasRun = false val workflow = Workflow.stateful( initialState = "unchanged state", render = { renderState -> runningSideEffect("only1") { trigger.drop(1).collect { + println("SAE: Enqueued handler message") actionSink.send( action( name = "triggerCollect", - isDeferrable = true ) { + println("SAE: Trigger handler action") state = it - // If we do not end the test within 1 main thread message we'll blow up. - assertTrue( - handler.sendMessage(theNextMessage), - message = "Could not send to handler. This test does not work without that." - ) } ) + mainChoreographer!!.postFrameCallback(frameCallback) } } renderState @@ -471,28 +392,36 @@ class AndroidRenderWorkflowInTest { val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = MutableStateFlow(Unit).asStateFlow(), runtimeConfig = runtimeConfig, workflowTracer = null, interceptors = emptyList() ) {} - val collectionJob = launch(context = Dispatchers.Main.immediate) { + val collectionJob = launch { // Collect this unconfined so we can get all the renderings faster than actions can // be processed. renderings.collect { - if (it.rendering == "changed state") { + println("SAE: Got rendering: $it, theFrameWasRun? $theFrameWasRun") + if (it == "changed state") { // The rendering we were looking for! - assertFalse(nextMessageRan, "The sent message ran :(.") - countDownLatch.countDown() + assertFalse( + theFrameWasRun, + "The callback on this frame was run before we" + + "got our rendering!" + ) + mainChoreographer!!.removeFrameCallback(frameCallback) + mutex.unlock() } } } - trigger.emit("changed state") + launch { + trigger.value = "changed state" + } - countDownLatch.await() + mutex.lock() collectionJob.cancel() } } diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt similarity index 91% rename from workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt rename to workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt index 9f93db7471..4404f65713 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.widget.FrameLayout import androidx.activity.ComponentActivity @@ -10,7 +10,12 @@ import androidx.lifecycle.viewModelScope import androidx.test.ext.junit.rules.ActivityScenarioRule import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.StatelessWorkflow +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import leakcanary.DetectLeaksAfterTestSuccess diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt similarity index 95% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt index 02e5bc00fd..433f06d8f1 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import androidx.annotation.VisibleForTesting import androidx.lifecycle.SavedStateHandle @@ -172,14 +172,14 @@ public fun renderWorkflowIn( workflowTracer: WorkflowTracer? = null, onOutput: suspend (OutputT) -> Unit = {} ): StateFlow = renderWorkflowIn( - workflow, - scope, - MutableStateFlow(prop), - savedStateHandle, - interceptors, - runtimeConfig, - workflowTracer, - onOutput + workflow = workflow, + scope = scope, + props = MutableStateFlow(prop), + savedStateHandle = savedStateHandle, + interceptors = interceptors, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + onOutput = onOutput ) /** @@ -273,14 +273,15 @@ public fun renderWorkflowIn( ): StateFlow { val restoredSnap = savedStateHandle?.get(KEY)?.snapshot val renderingsAndSnapshots = renderWorkflowIn( - workflow, - scope, - props, - restoredSnap, - interceptors, - runtimeConfig, - workflowTracer, - onOutput + workflow = workflow, + scope = scope, + props = props, + initialSnapshot = restoredSnap, + interceptors = interceptors, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + workflowFrameClock = AndroidFrameClock, + onOutput = onOutput, ) return renderingsAndSnapshots diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt similarity index 95% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt index 3936e04164..b9f229725c 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.os.Parcel import android.os.Parcelable diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt similarity index 94% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt index 4c78a97073..4ce542b8de 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt @@ -1,11 +1,11 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Bundle import androidx.savedstate.SavedStateRegistry import com.squareup.workflow1.TreeSnapshot -import com.squareup.workflow1.ui.TreeSnapshotSaver.Companion.fromSavedStateRegistry +import com.squareup.workflow1.android.TreeSnapshotSaver.Companion.fromSavedStateRegistry /** * Persistence aid for [TreeSnapshot]. Use [fromSavedStateRegistry] to create one diff --git a/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/WorkflowFrameClock.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/WorkflowFrameClock.kt new file mode 100644 index 0000000000..10ab4a831b --- /dev/null +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/WorkflowFrameClock.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.android + +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.monotonicFrameClock +import androidx.compose.ui.platform.AndroidUiDispatcher +import com.squareup.workflow1.WorkflowFrameClock + +@OptIn(ExperimentalComposeApi::class) +public object AndroidFrameClock : WorkflowFrameClock { + + private val composeAndroidFrameClock = AndroidUiDispatcher.Main.monotonicFrameClock + + override suspend fun resumeOnFrame() { + composeAndroidFrameClock.withFrameNanos { + println("SAE: With Frame Nanos Callback! at time: $it") + // no-op, we just need to resume at the frame barrier! + } + println("SAE: Resumed from `withFrameNanos`") + } +} diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index 150f639768..999e427923 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -11,8 +11,8 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup } public final class com/squareup/workflow1/RenderWorkflowKt { - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lcom/squareup/workflow1/WorkflowFrameClock;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lcom/squareup/workflow1/WorkflowFrameClock;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; } public final class com/squareup/workflow1/RenderingAndSnapshot { @@ -51,6 +51,15 @@ public final class com/squareup/workflow1/TreeSnapshot$Companion { public final fun parse (Lokio/ByteString;)Lcom/squareup/workflow1/TreeSnapshot; } +public abstract interface class com/squareup/workflow1/WorkflowFrameClock { + public static final field Companion Lcom/squareup/workflow1/WorkflowFrameClock$Companion; + public abstract fun resumeOnFrame (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/WorkflowFrameClock$Companion { + public final fun getDEFAULT_FRAME_CLOCK ()Lcom/squareup/workflow1/WorkflowFrameClock; +} + public abstract interface class com/squareup/workflow1/WorkflowInterceptor { public abstract 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 abstract fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; 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 075b7d155b..6cdbd08c06 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.yield /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -101,6 +100,9 @@ import kotlinx.coroutines.yield * @param runtimeConfig * Configuration parameters for the Workflow Runtime. * + * @param workflowFrameClock + * The frame clock that will be used to synchronize this Workflow Runtime. + * * @return * A [StateFlow] of [RenderingAndSnapshot]s that will emit any time the root workflow creates a new * rendering. @@ -114,18 +116,19 @@ public fun renderWorkflowIn( interceptors: List = emptyList(), runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, workflowTracer: WorkflowTracer? = null, + workflowFrameClock: WorkflowFrameClock = WorkflowFrameClock.DEFAULT_FRAME_CLOCK, onOutput: suspend (OutputT) -> Unit ): StateFlow> { val chainedInterceptor = interceptors.chained() val runner = WorkflowRunner( - scope, - workflow, - props, - initialSnapshot, - chainedInterceptor, - runtimeConfig, - workflowTracer + scope = scope, + protoWorkflow = workflow, + props = props, + snapshot = initialSnapshot, + interceptor = chainedInterceptor, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer ) // Rendering is synchronous, so we can run the first render pass before launching the runtime @@ -183,13 +186,12 @@ public fun renderWorkflowIn( // but remember the first render pass already occurred above, before this coroutine was even // launched. var actionResult: ActionProcessingResult = runner.waitForAction() - - if (actionResult is DeferredActionToBeApplied) { - // If we are deferring the first action, yield first to let any other actions queue up, so - // we can process as many as possible below. - yield() - actionResult = actionResult.applyAction.await() - } + println("SAE: After direct apply action: $actionResult.") + // + // if (actionResult is DeferredActionToBeApplied) { + // actionResult = actionResult.applyAction.await() + // println("SAE: After deferred apply action: $actionResult.") + // } if (shouldShortCircuitForUnchangedState(actionResult)) { chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped()) @@ -209,8 +211,10 @@ public fun renderWorkflowIn( conflate@ while (isActive && actionResult is ActionApplied<*> && actionResult.output == null) { conflationHasChangedState = conflationHasChangedState || actionResult.stateChanged + println("SAE: Before conflate applies another action.") // We may have more actions we can apply, this rendering could be stale. actionResult = runner.applyNextAvailableAction() + println("SAE: After conflate applies another action: $actionResult.") // If no actions processed, then no new rendering needed. Pass on to UI. if (actionResult == ActionsExhausted) break@conflate diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowFrameClock.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowFrameClock.kt new file mode 100644 index 0000000000..25f7ad29fe --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowFrameClock.kt @@ -0,0 +1,23 @@ +package com.squareup.workflow1 + +import kotlinx.coroutines.yield + +/** + * Basic frame clock providing synchronization for the Workflow Runtime. + */ +public fun interface WorkflowFrameClock { + + /** + * Resumes before the next 'frame' is processed. + */ + public suspend fun resumeOnFrame(): Unit + + companion object { + /** + * The default 'frame clock' is simply to yield the dispatcher to let actions queue up. + */ + val DEFAULT_FRAME_CLOCK = WorkflowFrameClock { + yield() + } + } +} 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 ded8d82165..bf15db9fa1 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 @@ -3,7 +3,6 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.ActionsExhausted -import com.squareup.workflow1.DeferredActionToBeApplied import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.NullableInitBox import com.squareup.workflow1.RenderContext @@ -24,14 +23,12 @@ 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.shouldDeferFirstAction import com.squareup.workflow1.trace import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.LAZY import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -220,13 +217,13 @@ internal class WorkflowNode( // Listen for any events. with(selector) { eventActionsChannel.onReceive { action -> - if (runtimeConfig.shouldDeferFirstAction() && action.isDeferrable) { - return@onReceive DeferredActionToBeApplied( - applyAction = async { - applyAction(action) - } - ) - } + // if (runtimeConfig.shouldDeferFirstAction()) { + // return@onReceive DeferredActionToBeApplied( + // applyAction = async { + // applyAction(action) + // } + // ) + // } return@onReceive applyAction(action) } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index f0d5a58b1e..6ff146e0c0 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -6,7 +6,6 @@ import com.squareup.workflow1.RenderingAndSnapshot import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowTracer import kotlinx.coroutines.CancellationException diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt index 90fec2e94b..e045b58a70 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt @@ -1,7 +1,6 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied -import com.squareup.workflow1.DeferredActionToBeApplied import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions @@ -333,15 +332,9 @@ internal class WorkflowRunnerTest { private fun WorkflowRunner<*, T, *>.runTillNextActionResult(): ActionApplied? = scope.run { val firstOutputDeferred = async { waitForAction() } runCurrent() - val actionResult = firstOutputDeferred.getCompleted() // If it is [ PropsUpdated] or any other ActionProcessingResult, will return as null. - val finalActionResult = if (actionResult is DeferredActionToBeApplied) { - runCurrent() - actionResult.applyAction.getCompleted() as? ActionApplied - } else { - actionResult as? ActionApplied - } - return@run finalActionResult + val actionResult = firstOutputDeferred.getCompleted() as? ActionApplied + return@run actionResult } @Suppress("TestFunctionName") diff --git a/workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt b/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt similarity index 100% rename from workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt rename to workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt diff --git a/workflow-runtime/src/jsMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt b/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt similarity index 100% rename from workflow-runtime/src/jsMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt rename to workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 9e6c49969e..1f462ae976 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -3,16 +3,6 @@ public final class com/squareup/workflow1/ui/ActivityWorkflowContentViewKt { public static final fun getWorkflowContentViewOrNull (Landroid/app/Activity;)Lcom/squareup/workflow1/ui/WorkflowLayout; } -public final class com/squareup/workflow1/ui/AndroidRenderWorkflowKt { - public static final fun removeWorkflowState (Landroidx/lifecycle/SavedStateHandle;)V - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; -} - public abstract interface class com/squareup/workflow1/ui/AndroidScreen : com/squareup/workflow1/ui/Screen { public abstract fun getViewFactory ()Lcom/squareup/workflow1/ui/ScreenViewFactory; } diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 89adc8b266..1d489d7e81 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // Needs to be API for the WorkflowInterceptor argument to WorkflowRunner.Config. api(project(":workflow-runtime")) + api(project(":workflow-runtime-android")) api(project(":workflow-ui:core-common")) compileOnly(libs.androidx.viewbinding) diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 8ad63aa2f1..ab2f7f1db0 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -1,15 +1,38 @@ +androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 -androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-jvm:1.8.1 androidx.annotation:annotation:1.8.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 -androidx.collection:collection:1.1.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.2 +androidx.compose.runtime:runtime-saveable-android:1.7.2 +androidx.compose.runtime:runtime-saveable:1.7.2 +androidx.compose.runtime:runtime:1.7.2 +androidx.compose.ui:ui-android:1.7.2 +androidx.compose.ui:ui-geometry-android:1.7.2 +androidx.compose.ui:ui-geometry:1.7.2 +androidx.compose.ui:ui-graphics-android:1.7.2 +androidx.compose.ui:ui-graphics:1.7.2 +androidx.compose.ui:ui-text-android:1.7.2 +androidx.compose.ui:ui-text:1.7.2 +androidx.compose.ui:ui-unit-android:1.7.2 +androidx.compose.ui:ui-unit:1.7.2 +androidx.compose.ui:ui-util-android:1.7.2 +androidx.compose.ui:ui-util:1.7.2 +androidx.compose:compose-bom:2024.09.02 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 +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.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 @@ -17,17 +40,22 @@ 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.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.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0 diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index 48859fad88..2541a3d3ac 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -1,15 +1,38 @@ +androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 -androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-jvm:1.8.1 androidx.annotation:annotation:1.8.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 -androidx.collection:collection:1.1.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.2 +androidx.compose.runtime:runtime-saveable-android:1.7.2 +androidx.compose.runtime:runtime-saveable:1.7.2 +androidx.compose.runtime:runtime:1.7.2 +androidx.compose.ui:ui-android:1.7.2 +androidx.compose.ui:ui-geometry-android:1.7.2 +androidx.compose.ui:ui-geometry:1.7.2 +androidx.compose.ui:ui-graphics-android:1.7.2 +androidx.compose.ui:ui-graphics:1.7.2 +androidx.compose.ui:ui-text-android:1.7.2 +androidx.compose.ui:ui-text:1.7.2 +androidx.compose.ui:ui-unit-android:1.7.2 +androidx.compose.ui:ui-unit:1.7.2 +androidx.compose.ui:ui-util-android:1.7.2 +androidx.compose.ui:ui-util:1.7.2 +androidx.compose:compose-bom:2024.09.02 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 +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.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 @@ -17,17 +40,22 @@ 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.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.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0