diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4afa9c32e..03264b2c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,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/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts index 82136d47a..51e2837ff 100644 --- a/workflow-runtime-android/build.gradle.kts +++ b/workflow-runtime-android/build.gradle.kts @@ -12,9 +12,13 @@ android { } dependencies { + val composeBom = platform(libs.androidx.compose.bom) + api(project(":workflow-runtime")) + api(libs.androidx.compose.ui.android) api(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(composeBom) implementation(project(":workflow-core")) androidTestImplementation(libs.androidx.activity.ktx) diff --git a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt index 58668fc7b..dd8d50e68 100644 --- a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1.android +import androidx.compose.ui.platform.AndroidUiDispatcher import app.cash.burst.Burst import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS @@ -20,17 +21,14 @@ import com.squareup.workflow1.asWorker import com.squareup.workflow1.renderChild import com.squareup.workflow1.runningWorker import com.squareup.workflow1.stateful -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow 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.runTest import org.junit.Before -import org.junit.Ignore import org.junit.Test import papa.Choreographers import java.util.concurrent.atomic.AtomicInteger @@ -42,18 +40,40 @@ class AndroidDispatchersRenderWorkflowInTest( private val runtime: RuntimeOptions = RuntimeOptions.DEFAULT ) { - @Ignore("See https://github.com/square/workflow-kotlin/issues/1311") + private val orderIndex = AtomicInteger(0) + + private fun resetOrderCounter() { + orderIndex.set(0) + } + + @Before + fun setup() { + resetOrderCounter() + } + + private fun expectInOrder( + expected: Int, + prefix: String = "" + ) { + val localActual = orderIndex.getAndIncrement() + assertEquals( + expected, + localActual, + "$prefix: This should have happened" + + " in a different order position:" + ) + } + @Test fun conflate_renderings_for_multiple_worker_actions_same_trigger() = runTest { - - val trigger = MutableStateFlow("unchanged state") - val emitted = mutableListOf() - var renderingsPassed = 0 + val trigger = MutableSharedFlow() + val renderingsConsumed = mutableListOf() + var renderingsProduced = 0 val countInterceptor = object : WorkflowInterceptor { override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { if (outcome is RenderPassesComplete<*>) { - renderingsPassed++ + renderingsProduced++ } } } @@ -62,10 +82,10 @@ class AndroidDispatchersRenderWorkflowInTest( initialState = "unchanged state", render = { renderState -> runningWorker( - worker = trigger.drop(1).asWorker(), + worker = trigger.asWorker(), key = "Worker1" ) { - action("") { + action("childHandleWorker") { val newState = "$it+u1" state = newState setOutput(newState) @@ -78,33 +98,33 @@ class AndroidDispatchersRenderWorkflowInTest( initialState = "unchanged state", render = { renderState -> renderChild(childWorkflow) { childOutput -> - action("childHandler") { + action("childHandleOutput") { state = childOutput } } runningWorker( - worker = trigger.drop(1).asWorker(), + worker = trigger.asWorker(), key = "Worker2" ) { - action("") { + action("handleWorker2") { // Update the state in order to show conflation. state = "$state+u2" } } runningWorker( - worker = trigger.drop(1).asWorker(), + worker = trigger.asWorker(), key = "Worker3" ) { - action("") { + action("handleWorker3") { // Update the state in order to show conflation. state = "$state+u3" } } runningWorker( - worker = trigger.drop(1).asWorker(), + worker = trigger.asWorker(), key = "Worker4" ) { - action("") { + action("handleWorker4") { // Update the state in order to show conflation. state = "$state+u4" // Output only on the last one! @@ -118,58 +138,69 @@ class AndroidDispatchersRenderWorkflowInTest( val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = props, - runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), + runtimeConfig = runtime.runtimeConfig, workflowTracer = null, interceptors = listOf(countInterceptor) ) { } - val renderedMutex = Mutex(locked = true) + val renderedCompletedUpdate = Mutex(locked = true) - val collectionJob = launch { + val collectionJob = launch(AndroidUiDispatcher.Main) { renderings.collect { - emitted += it + renderingsConsumed += it if (it == "state change+u1+u2+u3+u4") { - renderedMutex.unlock() + // We expect to be able to consume our final rendering *before* the end of the frame. + expectInOrder(0) + renderedCompletedUpdate.unlock() } } } - testScheduler.advanceUntilIdle() - - launch { - trigger.value = "state change" + launch(AndroidUiDispatcher.Main) { + Choreographers.postOnFrameRendered { + // We are expecting this to happen last, after we get the rendering! + expectInOrder(1) + } + trigger.emit("state change") } - testScheduler.advanceUntilIdle() - - renderedMutex.lock() - + renderedCompletedUpdate.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.") + // Regardless only ever 2 renderings are consumed as the compose dispatcher drains all of + // the coroutines to update state before the collector can consume a rendering. + assertEquals( + expected = 2, + actual = renderingsConsumed.size, + message = "Expected 2 consumed renderings." + ) + // There are 2 attempts to produce a rendering for Conflate (initial and then the update.) + // And otherwise there are *5* attempts to produce a new rendering. + val expected = if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 5 + assertEquals( + expected = if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 5, + actual = renderingsProduced, + message = "Expected $expected renderings to be produced (passed signal to interceptor)." + ) assertEquals( - 2, - renderingsPassed, - "Expected only 2 renderings passed to interceptor when conflating actions." + expected = "state change+u1+u2+u3+u4", + actual = renderingsConsumed.last() ) - assertEquals("state change+u1+u2+u3+u4", emitted.last()) } - @Ignore("See https://github.com/square/workflow-kotlin/issues/1311") @Test fun conflate_renderings_for_multiple_side_effect_actions() = runTest { - val trigger = MutableStateFlow("unchanged state") - val emitted = mutableListOf() - var renderingsPassed = 0 + val trigger = MutableSharedFlow() + val renderingsConsumed = mutableListOf() + var renderingsProduced = 0 val countInterceptor = object : WorkflowInterceptor { override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { if (outcome is RenderPassesComplete<*>) { - renderingsPassed++ + renderingsProduced++ } } } @@ -178,7 +209,7 @@ class AndroidDispatchersRenderWorkflowInTest( initialState = "unchanged state", render = { renderState -> runningSideEffect("childSideEffect") { - trigger.drop(1).collect { + trigger.collect { actionSink.send( action( name = "handleChildSideEffectAction", @@ -202,7 +233,7 @@ class AndroidDispatchersRenderWorkflowInTest( } } runningSideEffect("parentSideEffect") { - trigger.drop(1).collect { + trigger.collect { actionSink.send( action( name = "handleParentSideEffectAction", @@ -219,44 +250,56 @@ class AndroidDispatchersRenderWorkflowInTest( val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = props, - runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS), + runtimeConfig = runtime.runtimeConfig, workflowTracer = null, interceptors = listOf(countInterceptor) ) { } - val renderedMutex = Mutex(locked = true) + val renderedCompletedUpdate = Mutex(locked = true) - val collectionJob = launch { + val collectionJob = launch(AndroidUiDispatcher.Main) { renderings.collect { - emitted += it + renderingsConsumed += it if (it == "state change+u1+u2") { - renderedMutex.unlock() + // We expect to get our completed rendering consumed before the end of the frame. + expectInOrder(0) + renderedCompletedUpdate.unlock() } } } - testScheduler.advanceUntilIdle() - - launch { - trigger.value = "state change" + launch(AndroidUiDispatcher.Main) { + Choreographers.postOnFrameRendered { + // We are expecting this to happen last, after we get the rendering! + expectInOrder(1) + } + trigger.emit("state change") } - testScheduler.advanceUntilIdle() - - renderedMutex.lock() - + renderedCompletedUpdate.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.") + // Regardless only ever 2 renderings are consumed as the compose dispatcher drains all of + // the coroutines to update state before the collector can consume a rendering. assertEquals( - 2, - renderingsPassed, - "Expected only 2 renderings passed to interceptor when conflating actions." + expected = 2, + actual = renderingsConsumed.size, + message = "Expected 2 consumed renderings." + ) + // There are 2 attempts to produce a rendering for Conflate (initial and then the update.) + // And otherwise there are *3* attempts to produce a new rendering. + val expected = if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 3 + assertEquals( + expected = if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 3, + actual = renderingsProduced, + message = "Expected $expected renderings to be produced (passed signal to interceptor)." + ) + assertEquals( + expected = "state change+u1+u2", + actual = renderingsConsumed.last() ) - assertEquals("state change+u1+u2", emitted.last()) } private class SimpleScreen( @@ -264,34 +307,10 @@ class AndroidDispatchersRenderWorkflowInTest( val callback: () -> Unit, ) - private val orderIndex = AtomicInteger(0) - - private fun resetOrderCounter() { - orderIndex.set(0) - } - - @Before - fun setup() { - resetOrderCounter() - } - - private fun expectInOrder( - expected: Int, - prefix: String = "" - ) { - val localActual = orderIndex.getAndIncrement() - assertEquals( - expected, - localActual, - "$prefix: This should have happened" + - " in a different order position:" - ) - } - @Test fun all_runtimes_handle_side_effect_actions_before_the_next_frame() = runTest { - val completedMutex = Mutex(locked = true) + val renderingUpdateComplete = Mutex(locked = true) val trigger = MutableSharedFlow() val workflow = Workflow.stateful( @@ -306,40 +325,40 @@ class AndroidDispatchersRenderWorkflowInTest( } ) + // We are rendering using Compose's AndroidUiDispatcher.Main. val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = MutableStateFlow(Unit).asStateFlow(), runtimeConfig = runtime.runtimeConfig, workflowTracer = null, interceptors = emptyList() ) {} - val collectionJob = launch(Dispatchers.Main.immediate) { + val collectionJob = launch(AndroidUiDispatcher.Main) { renderings.collect { if (it == "changed state") { // The rendering we were looking for! - expectInOrder(1) - completedMutex.unlock() - } else { expectInOrder(0) + renderingUpdateComplete.unlock() + } else { Choreographers.postOnFrameRendered { // We are expecting this to happen last, after we get the rendering! - expectInOrder(2) + expectInOrder(1) } trigger.emit("changed state") } } } - completedMutex.lock() + renderingUpdateComplete.lock() collectionJob.cancel() } @Test fun all_runtimes_handle_rendering_events_before_next_frame() = runTest { - val completedMutex = Mutex(locked = true) + val renderingUpdateComplete = Mutex(locked = true) val workflow = Workflow.stateful( initialState = "neverends", render = { renderState -> @@ -355,24 +374,23 @@ class AndroidDispatchersRenderWorkflowInTest( val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope + - Dispatchers.Main.immediate, + AndroidUiDispatcher.Main, props = MutableStateFlow(Unit).asStateFlow(), runtimeConfig = runtime.runtimeConfig, workflowTracer = null, interceptors = emptyList() ) {} - val collectionJob = launch(Dispatchers.Main.immediate) { + val collectionJob = launch(AndroidUiDispatcher.Main) { renderings.collect { if (it.name == "neverends+neverends") { // The rendering we were looking for after the event! - expectInOrder(1) - completedMutex.unlock() - } else { expectInOrder(0) + renderingUpdateComplete.unlock() + } else { Choreographers.postOnFrameRendered { // This should be happening last! - expectInOrder(2) + expectInOrder(1) } // First rendering, lets call it. it.callback() @@ -380,7 +398,7 @@ class AndroidDispatchersRenderWorkflowInTest( } } - completedMutex.lock() + renderingUpdateComplete.lock() collectionJob.cancel() } } diff --git a/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt index e624f306e..921bdfea0 100644 --- a/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt @@ -1,6 +1,9 @@ +@file:OptIn(ExperimentalStdlibApi::class) + package com.squareup.workflow1.android import androidx.annotation.VisibleForTesting +import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.SavedStateHandle import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions @@ -8,6 +11,7 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowTracer import com.squareup.workflow1.renderWorkflowIn +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly @@ -15,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus /** * An Android `ViewModel`-friendly wrapper for [com.squareup.workflow1.renderWorkflowIn], @@ -272,9 +277,17 @@ public fun renderWorkflowIn( onOutput: suspend (OutputT) -> Unit = {} ): StateFlow { val restoredSnap = savedStateHandle?.get(KEY)?.snapshot + + // Add in Compose's AndroidUiDispatcher.Main by default if none is specified. + val updatedContext = if (scope.coroutineContext[CoroutineDispatcher.Key] == null) { + scope.coroutineContext + AndroidUiDispatcher.Main + } else { + scope.coroutineContext + } + val renderingsAndSnapshots = renderWorkflowIn( workflow, - scope, + scope + updatedContext, props, restoredSnap, interceptors, 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..75bcc2269 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -178,10 +178,10 @@ 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.waitAndProcessAction() if (shouldShortCircuitForUnchangedState(actionResult)) { chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped()) @@ -189,8 +189,8 @@ public fun renderWorkflowIn( continue@outer } - // After resuming from runner.processAction() our coroutine could now be cancelled, check so - // we don't surprise anyone with an unexpected rendering pass. Show's over, go home. + // After resuming from runner.waitAndProcessAction() our coroutine could now be cancelled, + // check so we don't surprise anyone with an unexpected rendering pass. Show's over, go home. if (!isActive) return@launch // Next Render Pass. @@ -201,16 +201,14 @@ public fun renderWorkflowIn( 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) + // This will check for any actions that are immediately available and apply them. + actionResult = runner.applyNextAvailableAction() // If no actions processed, then no new rendering needed. Pass on to UI. if (actionResult == ActionsExhausted) break@conflate // Skip rendering if we had unchanged state, keep draining actions. - if (shouldShortCircuitForUnchangedState( - actionResult = actionResult, - ) - ) { + if (shouldShortCircuitForUnchangedState(actionResult = actionResult)) { if (conflationHasChangedState) { chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped(endOfTick = false)) // An earlier render changed state, so we need to pass that to the UI then we @@ -222,9 +220,7 @@ public fun renderWorkflowIn( continue@outer } - // Make sure the runtime has not been cancelled from runner.processAction() - if (!isActive) return@launch - + // Render pass for the updated state from the action applied. 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..c8a272444 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 @@ -148,17 +149,27 @@ internal class SubtreeManager( /** * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance * is managing. + */ + fun onNextChildAction(selector: SelectBuilder) { + children.forEachActive { child -> + child.workflowNode.onNextAction(selector) + } + } + + /** + * Will try to apply any actions immediately available in our children's actions queues. * - * @return [Boolean] whether or not the children action queues are empty. + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. */ - fun onNextChildAction(selector: SelectBuilder): Boolean { - var empty = true + fun applyNextAvailableChildAction(): ActionProcessingResult { 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 + val result = child.workflowNode.applyNextAvailableAction() + if (result != ActionsExhausted) { + return result + } } - return empty + 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..947375da4 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,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.NullableInitBox import com.squareup.workflow1.RenderContext @@ -27,8 +28,6 @@ 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.cancel import kotlinx.coroutines.channels.Channel @@ -207,16 +206,10 @@ internal class WorkflowNode( * a re-render, e.g. my state changed or a child state changed. * * 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 onNextAction(selector: SelectBuilder) { // Listen for any child workflow updates. - var empty = subtreeManager.onNextChildAction(selector) - - empty = empty && (eventActionsChannel.isEmpty || eventActionsChannel.isClosedForReceive) + subtreeManager.onNextChildAction(selector) // Listen for any events. with(selector) { @@ -224,7 +217,24 @@ internal class WorkflowNode( return@onReceive applyAction(action) } } - return empty + } + + /** + * Will try to apply any immediately available actions in this action queue or any of our + * children's. + * + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. + */ + fun applyNextAvailableAction(): ActionProcessingResult { + val result = subtreeManager.applyNextAvailableChildAction() + + if (result == ActionsExhausted) { + return eventActionsChannel.tryReceive().getOrNull()?.let { action -> + applyAction(action) + } ?: ActionsExhausted + } + return result } /** 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..392bf849e 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 @@ -5,10 +5,8 @@ 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 +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 [waitAndProcessAction], and must be called again + * between every subsequent call to [waitAndProcessAction]. */ fun nextRendering(): RenderingAndSnapshot { return interceptor.onRenderAndSnapshot(currentProps, { props -> @@ -83,24 +80,25 @@ internal class WorkflowRunner( * 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 { + suspend fun waitAndProcessAction(): ActionProcessingResult { // If waitForAction 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.onNextAction(this) } } + /** + * Will try to apply any immediately available actions for this runtime (no suspending). + * + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. + */ + 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/WorkflowRunnerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt index 18e657ce4..03eefae8c 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 @@ -86,7 +86,7 @@ internal class WorkflowRunnerTest { } } - @Test fun initial_processActions_does_not_handle_initial_props() { + @Test fun initial_waitAndProcessActions_does_not_handle_initial_props() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -102,14 +102,14 @@ internal class WorkflowRunnerTest { ) runner.nextRendering() - val outputDeferred = scope.async { runner.processAction() } + val outputDeferred = scope.async { runner.waitAndProcessAction() } scope.runCurrent() assertTrue(outputDeferred.isActive) } } - @Test fun initial_processActions_handles_props_changed_after_initialization() { + @Test fun initial_waitAndProcessActions_handles_props_changed_after_initialization() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -131,7 +131,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.waitAndProcessAction() } assertTrue(output.isActive) // Resume the dispatcher to start the coroutines and process the new props value. @@ -146,7 +146,7 @@ internal class WorkflowRunnerTest { } } - @Test fun processActions_handles_workflow_update() { + @Test fun waitAndProcessActions_handles_workflow_update() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -179,7 +179,7 @@ internal class WorkflowRunnerTest { } } - @Test fun processActions_handles_concurrent_props_change_and_workflow_update() { + @Test fun waitAndProcessActions_handles_concurrent_props_change_and_workflow_update() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -219,7 +219,7 @@ internal class WorkflowRunnerTest { } } - @Test fun cancelRuntime_does_not_interrupt_processActions() { + @Test fun cancelRuntime_does_not_interrupt_waitAndProcessActions() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -229,7 +229,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val output = scope.async { runner.processAction() } + val output = scope.async { runner.waitAndProcessAction() } scope.runCurrent() assertTrue(output.isActive) @@ -272,7 +272,7 @@ internal class WorkflowRunnerTest { } } - @Test fun cancelling_scope_interrupts_processActions() { + @Test fun cancelling_scope_interrupts_waitAndProcessActions() { runtimeTestRunner.runParametrizedTest( paramSource = runtimeOptions, before = ::setup, @@ -283,7 +283,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val actionResult = scope.async { runner.processAction() } + val actionResult = scope.async { runner.waitAndProcessAction() } scope.runCurrent() assertTrue(actionResult.isActive) @@ -314,7 +314,7 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) runner.nextRendering() - val actionResult = scope.async { runner.processAction() } + val actionResult = scope.async { runner.waitAndProcessAction() } scope.runCurrent() assertTrue(actionResult.isActive) assertNull(cancellationException) @@ -330,7 +330,7 @@ internal class WorkflowRunnerTest { @Suppress("UNCHECKED_CAST") private fun WorkflowRunner<*, T, *>.runTillNextActionResult(): ActionApplied? = scope.run { - val firstOutputDeferred = async { processAction() } + val firstOutputDeferred = async { waitAndProcessAction() } runCurrent() // If it is [ PropsUpdated] or any other ActionProcessingResult, will return as null. firstOutputDeferred.getCompleted() as? ActionApplied diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 8ad63aa2f..ab2f7f1db 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 48859fad8..2541a3d3a 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