diff --git a/settings.gradle.kts b/settings.gradle.kts index f4982b30f..00f951b06 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 567217d4f..53330a711 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 240699c8f..d30b85c46 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 5c31f2ac1..526530a64 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 aa1cc35dd..d78ac8592 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 64c2cd012..3cdf84d3c 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 f2cbd0469..a9baf57d5 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 000000000..0631e0aed --- /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 000000000..e69de29bb diff --git a/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts new file mode 100644 index 000000000..d99bbbee8 --- /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 000000000..5f09c5c15 --- /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 000000000..125820472 --- /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 000000000..ab1cce459 --- /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, start = UNDISPATCHED) { + // 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, start = UNDISPATCHED) { + // 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, start = UNDISPATCHED) { + // 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 78a25f097..075b7d155 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 09fb7608a..688e96fe7 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 04c680fb2..ded8d8216 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 9eb66bb1b..6ff146e0c 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,14 +1,11 @@ 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 import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowTracer import kotlinx.coroutines.CancellationException @@ -19,7 +16,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 +61,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 +73,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 909bfff5c..1d68d4f6f 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 616630284..01fa388a7 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 18e657ce4..90fec2e94 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")