From c02726538a390c7ad533eaa5d051792b6aa0e5a8 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Wed, 27 Aug 2025 13:07:58 -0700 Subject: [PATCH 01/11] wip trying to implement jwilson's idea in workflow --- samples/containers/thingy/build.gradle.kts | 24 + samples/containers/thingy/lint-baseline.xml | 26 + .../thingy/src/main/AndroidManifest.xml | 23 + .../sample/thingy/HelloBackButtonActivity.kt | 68 +++ .../com/squareup/sample/thingy/MyWorkflow.kt | 51 ++ .../squareup/sample/thingy/ThingyWorkflow.kt | 485 ++++++++++++++++++ .../res/layout/hello_back_button_layout.xml | 15 + .../thingy/src/main/res/values/strings.xml | 3 + .../thingy/src/main/res/values/styles.xml | 8 + settings.gradle.kts | 1 + .../workflow1/ui/navigation/Thingy.kt | 4 + 11 files changed, 708 insertions(+) create mode 100644 samples/containers/thingy/build.gradle.kts create mode 100644 samples/containers/thingy/lint-baseline.xml create mode 100644 samples/containers/thingy/src/main/AndroidManifest.xml create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt create mode 100644 samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml create mode 100644 samples/containers/thingy/src/main/res/values/strings.xml create mode 100644 samples/containers/thingy/src/main/res/values/styles.xml create mode 100644 workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt diff --git a/samples/containers/thingy/build.gradle.kts b/samples/containers/thingy/build.gradle.kts new file mode 100644 index 0000000000..84b4a6a5cd --- /dev/null +++ b/samples/containers/thingy/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("android-sample-app") + id("android-ui-tests") + id("kotlin-parcelize") +} + +android { + defaultConfig { + applicationId = "com.squareup.sample.thingy" + } + namespace = "com.squareup.sample.thingy" +} + +dependencies { + debugImplementation(libs.squareup.leakcanary.android) + + implementation(libs.androidx.activity.ktx) + + implementation(project(":samples:containers:android")) + implementation(project(":workflow-ui:core-android")) + implementation(project(":workflow-ui:core-common")) +} diff --git a/samples/containers/thingy/lint-baseline.xml b/samples/containers/thingy/lint-baseline.xml new file mode 100644 index 0000000000..a1c902b19e --- /dev/null +++ b/samples/containers/thingy/lint-baseline.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/samples/containers/thingy/src/main/AndroidManifest.xml b/samples/containers/thingy/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a682370fd9 --- /dev/null +++ b/samples/containers/thingy/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt new file mode 100644 index 0000000000..7211ad4a66 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt @@ -0,0 +1,68 @@ +@file:OptIn(WorkflowExperimentalRuntime::class) + +package com.squareup.sample.thingy + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.withRegistry +import com.squareup.workflow1.ui.workflowContentView +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +private val viewRegistry = SampleContainers + +class HelloBackButtonActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val model: HelloBackButtonModel by viewModels() + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) + + lifecycleScope.launch { + model.waitForExit() + finish() + } + } + + companion object { + init { + Timber.plant(Timber.DebugTree()) + } + } +} + +class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() { + private val running = Job() + + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = TODO("MyWorkflow()"), + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + ) { + // This workflow handles the back button itself, so the activity can't. + // Instead, the workflow emits an output to signal that it's time to shut things down. + running.complete() + }.reportNavigation { + Timber.i("Navigated to %s", it) + } + } + + /** Blocks until the workflow signals that it's time to shut things down. */ + suspend fun waitForExit() = running.join() +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt new file mode 100644 index 0000000000..e49b3c50ce --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -0,0 +1,51 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +enum class MyOutputs { + Back, + Done, +} + +fun MyWorkflow( + child1: Workflow, + child2: Workflow, + child3: Workflow, + networkCall: suspend (String) -> String +) = thingyWorkflow { + + // Step 1 + showWorkflow(child1) { output -> + when (output) { + "back" -> emitOutput(MyOutputs.Back) + "next" -> { + // Step 2 + val childResult = showWorkflow(child2) { output -> + if (output == "back") { + // Removes child2 from the stack, cancels the output handler from step 1, and just + // leaves child1 rendering. + goBack() + } else { + finishWith(output) + } + } + + // TODO: Show a loading screen automatically. + val networkResult = networkCall(childResult) + + // Step 3: Show a workflow for 3 seconds then finish. + launch { + delay(3.seconds) + emitOutput(MyOutputs.Done) + } + showWorkflow(child3, networkResult) {} + } + + else -> {} + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt new file mode 100644 index 0000000000..44cced4574 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt @@ -0,0 +1,485 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code + * in [block]. + * + * [block] can render child workflows by calling [RootScope.showWorkflow]. It can emit outputs to + * its parent by calling [RootScope.emitOutput], and access its props via [RootScope.props]. + * + * # Examples + * + * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: + * ``` + * thingyWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * } + * ``` + * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed + * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The + * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that + * called their `showWorkflow`, until the next output is emitted. + * + * Contrast with calls in series: + * ``` + * thingyWorkflow { + * showWorkflow(child1) { finishWith(Unit) } + * showWorkflow(child2) { finishWith(Unit) } + * showWorkflow(child3) { } + * } + * ``` + * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto + * the stack, `child1` will be removed from the stack and replaced with `child2`. + * + * These can be combined: + * ``` + * thingyWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * // goBack(), or + * finishWith(Unit) + * } + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * ``` + * This code will show `child1` immediately, then when it emits an output show `child2`. When + * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call + * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` + * again. + */ +public fun thingyWorkflow( + block: suspend RootScope.() -> Unit +): Workflow> = ThingyWorkflow(block) + +@DslMarker +annotation class ThingyDsl + +@ThingyDsl +public interface RootScope : CoroutineScope { + val props: StateFlow + + /** + * Emits an output to the [thingyWorkflow]'s parent. + */ + fun emitOutput(output: OutputT) + + /** + * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. + * + * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call + * doesn't finish before another output is emitted, multiple callbacks can run concurrently. + * + * When [onOutput] calls [ShowWorkflowScope.finishWith], this workflow stops rendering, its + * rendering is removed from the backstack, and any running output handlers are cancelled. + * + * Note that top-level workflows inside a [thingyWorkflow] cannot call + * [ShowWorkflowChildScope.goBack] because the parent doesn't necessarily support that operation. + */ + suspend fun showWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: suspend ShowWorkflowScope.(output: ChildOutputT) -> Unit + ): R +} + +@ThingyDsl +public sealed interface ShowWorkflowScope : CoroutineScope { + + /** + * Emits an output to the [thingyWorkflow]'s parent. + */ + fun emitOutput(output: OutputT) + + /** + * Causes the [showWorkflow] call that ran the output handler that was passed this scope to return + * [value] and cancels any output handlers still running for that workflow. The workflow is + * removed from the stack and will no longer be rendered. + */ + suspend fun finishWith(value: R): Nothing + + /** + * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. + * + * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call + * doesn't finish before another output is emitted, multiple callbacks can run concurrently. + * + * When [onOutput] calls [ShowWorkflowScope.finishWith] or [ShowWorkflowChildScope.goBack], this + * workflow stops rendering, its rendering is removed from the backstack, and any running output + * handlers are cancelled. [ShowWorkflowChildScope.goBack] will also cancel the output handler + * of the parent workflow that called this [showWorkflow]. + */ + suspend fun showWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: suspend ShowWorkflowChildScope.(output: ChildOutputT) -> Unit + ): R +} + +@ThingyDsl +public sealed interface ShowWorkflowChildScope : ShowWorkflowScope { + /** + * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] + * from the stack, and cancels that parent output handler coroutine (and thus all child workflow + * coroutines as well). + */ + suspend fun goBack(): Nothing +} + +public suspend inline fun RootScope<*, OutputT>.showWorkflow( + workflow: Workflow, + noinline onOutput: suspend ShowWorkflowScope.(output: ChildOutputT) -> Unit +): R = showWorkflow(workflow, props = Unit, onOutput) + +public suspend inline fun RootScope<*, *>.showWorkflow( + workflow: Workflow, + props: ChildPropsT, +): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } + +public suspend inline fun RootScope<*, *>.showWorkflow( + workflow: Workflow, +): Nothing = showWorkflow(workflow, props = Unit) { error("Cannot call") } + +public suspend inline fun ShowWorkflowScope.showWorkflow( + workflow: Workflow, + noinline onOutput: suspend ShowWorkflowChildScope.(output: ChildOutputT) -> Unit +): R = showWorkflow(workflow, props = Unit, onOutput) + +public suspend inline fun ShowWorkflowScope<*, *>.showWorkflow( + workflow: Workflow, + props: ChildPropsT, +): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } + +public suspend inline fun ShowWorkflowScope<*, *>.showWorkflow( + workflow: Workflow, +): Nothing = showWorkflow(workflow, props = Unit) { error("Cannot call") } + +private class RootScopeImpl( + override val props: MutableStateFlow, + private val actionSink: Sink>, + coroutineScope: CoroutineScope, +) : RootScope, CoroutineScope by coroutineScope { + + override fun emitOutput(output: OutputT) { + actionSink.send(action("emitOutput") { + setOutput(output) + }) + } + + override suspend fun showWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: suspend ShowWorkflowScope.(ChildOutputT) -> Unit + ): R = showWorkflow( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = null + ) +} + +private class ShowWorkflowChildScopeImpl( + private val actionSink: Sink>, + coroutineScope: CoroutineScope, + private val onFinish: (R) -> Unit, + private val thisFrame: Frame<*, *, *, *, *>, + private val parentFrame: Frame<*, *, *, *, *>?, +) : ShowWorkflowChildScope, CoroutineScope by coroutineScope { + + override fun emitOutput(output: OutputT) { + actionSink.send(action("emitOutput") { + setOutput(output) + }) + } + + @Suppress("UNCHECKED_CAST") + override suspend fun showWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: suspend ShowWorkflowChildScope.(ChildOutputT) -> Unit + ): R = showWorkflow( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = thisFrame, + ) + + override suspend fun finishWith(value: R): Nothing { + onFinish(value) + cancelSelf() + } + + override suspend fun goBack(): Nothing { + // If parent is null, goBack will not be exposed and will never be called. + val parent = checkNotNull(parentFrame) { "goBack called on root scope" } + parent.popTo() + + cancelSelf() + } +} + +private class Frame( + private val workflow: Workflow, + private val props: ChildPropsT, + private val callerJob: Job, + val frameScope: CoroutineScope, + private val onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, + private val actionSink: Sink>, + private val parent: Frame<*, *, *, *, *>?, +) { + private val result = CompletableDeferred(parent = frameScope.coroutineContext.job) + + suspend fun awaitResult(): R = result.await() + + fun renderWorkflow( + context: StatefulWorkflow.RenderContext + ): Screen = context.renderChild( + child = workflow, + props = props, + handler = ::onOutput + ) + + /** + * Pops everything off the stack that comes after this. + */ + fun popTo() { + actionSink.send(action("popTo") { + val stack = state.stack + val index = stack.indexOf(this@Frame) + check(index != -1) { "Frame was not in the stack!" } + + // Cancel all the frames we're about to drop, starting from the top. + for (i in stack.lastIndex downTo index + 1) { + // Don't just cancel the frame job, since that would only cancel output handlers the frame + // is running. We want to cancel the whole parent's output handler that called showWorkflow, + // in case the showWorkflow is in a try/catch that tries to make other suspending calls. + stack[i].callerJob.cancel() + } + + val newStack = stack.take(index + 1) + state = state.copy(stack = newStack) + }) + } + + private fun onOutput(output: ChildOutputT): WorkflowAction { + var canAcceptAction = true + var action: WorkflowAction? = null + val sink = object : Sink> { + override fun send(value: WorkflowAction) { + val sendToSink = synchronized(result) { + if (canAcceptAction) { + action = value + canAcceptAction = false + false + } else { + true + } + } + if (sendToSink) { + actionSink.send(value) + } + } + } + + // Run synchronously until first suspension point since in many cases it will immediately + // either call showWorkflow, finishWith, or goBack, and so then we can just return that action + // immediately instead of needing a whole separate render pass. + frameScope.launch(start = CoroutineStart.UNDISPATCHED) { + val showScope = ShowWorkflowChildScopeImpl( + sink, + coroutineScope = this, + onFinish = { + // This will eventually cancel the frame scope. + result.complete(it) + // TODO figure out how to coalesce this action into the one for showWorkflow. WorkStealingDispatcher? + sink.send(action("unshowWorkflow") { + state = state.removeFrame(this@Frame) + }) + }, + thisFrame = this@Frame, + parentFrame = parent + ) + onOutput(showScope, output) + } + + // Once the coroutine has suspended, all sends must go to the real sink. + return synchronized(result) { + canAcceptAction = false + action ?: WorkflowAction.noAction() + } + } +} + +// TODO concurrent calls to this function on the same scope should cancel/remove prior calls. +// Or maybe just enforce the same thing as Flow, only allow calls from the same job? +private suspend fun showWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, + actionSink: Sink>, + parentFrame: Frame<*, *, *, *, *>?, +): R { + val callerContext = currentCoroutineContext() + val callerJob = callerContext.job + val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) + lateinit var frame: Frame + + // Tell the workflow runtime to start rendering the new workflow. + actionSink.sendAndAwaitApplication(action("showWorkflow") { + frame = Frame( + workflow = workflow, + props = props, + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parentFrame, + ) + state = state.appendFrame(frame) + }) + + return try { + frame.awaitResult() + } finally { + frameScope.cancel() + actionSink.send(action("unshowWorkflow") { + state = state.removeFrame(frame) + }) + } +} + +private class ThingyState( + val stack: List>, + val props: MutableStateFlow, +) { + + fun copy(stack: List> = this.stack) = ThingyState( + stack = stack, + props = props + ) + + fun appendFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack + frame) + fun removeFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack - frame) +} + +private class ThingyWorkflow( + private val block: suspend RootScope.() -> Unit +) : StatefulWorkflow< + PropsT, + ThingyState, + OutputT, + BackStackScreen + >() { + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ): ThingyState { + return ThingyState( + stack = emptyList(), + props = MutableStateFlow(props) + ) + } + + override fun onPropsChanged( + old: PropsT, + new: PropsT, + state: ThingyState + ): ThingyState = state.apply { + props.value = new + } + + override fun render( + renderProps: PropsT, + renderState: ThingyState, + context: RenderContext + ): BackStackScreen { + context.runningSideEffect("main") { + @Suppress("UNCHECKED_CAST") + val scope = RootScopeImpl( + props = renderState.props as MutableStateFlow, + actionSink = context.actionSink, + coroutineScope = this, + ) + block(scope) + } + + val renderings = renderState.stack.map { frame -> + @Suppress("UNCHECKED_CAST") + (frame as Frame).renderWorkflow(context) + } + + // TODO show a loading screen if renderings is empty. + return renderings.toBackStackScreen() + } + + override fun snapshotState(state: ThingyState): Snapshot? = null +} + +private suspend fun cancelSelf(): Nothing { + val job = currentCoroutineContext().job + job.cancel() + job.ensureActive() + error("Nonsense") +} + +private suspend fun < + PropsT, + StateT, + OutputT + > Sink>.sendAndAwaitApplication( + action: WorkflowAction +) { + suspendCancellableCoroutine { continuation -> + val resumingAction = object : WorkflowAction() { + // Pipe through debugging name to the original action. + override val debuggingName: String + get() = action.debuggingName + + override fun Updater.apply() { + // Don't execute anything if the caller was cancelled while we were in the queue. + if (!continuation.isActive) return + + with(action) { + // Forward our Updater to the real action. + apply() + } + continuation.resume(Unit) + } + } + send(resumingAction) + } +} diff --git a/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml new file mode 100644 index 0000000000..0e8b71c480 --- /dev/null +++ b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/samples/containers/thingy/src/main/res/values/strings.xml b/samples/containers/thingy/src/main/res/values/strings.xml new file mode 100644 index 0000000000..6e0e2822ad --- /dev/null +++ b/samples/containers/thingy/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Hello Sal + diff --git a/samples/containers/thingy/src/main/res/values/styles.xml b/samples/containers/thingy/src/main/res/values/styles.xml new file mode 100644 index 0000000000..e2331afcc2 --- /dev/null +++ b/samples/containers/thingy/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index ced4e51458..d09be320c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include( ":samples:containers:app-raven", ":samples:containers:android", ":samples:containers:common", + ":samples:containers:thingy", ":samples:containers:hello-back-button", ":samples:containers:poetry", ":samples:dungeon:app", diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt new file mode 100644 index 0000000000..524d4feebd --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt @@ -0,0 +1,4 @@ +package com.squareup.workflow1.ui.navigation + +public class Thingy : Stateful { +} From 82600bb9c738f0b714e30096f574c30fcb9093c6 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 08:12:02 -0700 Subject: [PATCH 02/11] rename to BackStackWorkflow, follow proper helper workflow pattern --- ...gyWorkflow.kt => BackStackWorkflowImpl.kt} | 183 ++++++++++-------- .../com/squareup/sample/thingy/MyWorkflow.kt | 2 +- 2 files changed, 103 insertions(+), 82 deletions(-) rename samples/containers/thingy/src/main/java/com/squareup/sample/thingy/{ThingyWorkflow.kt => BackStackWorkflowImpl.kt} (85%) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt similarity index 85% rename from samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt rename to samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index 44cced4574..89a5368ea4 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ThingyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -23,18 +23,31 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +/** + * Creates a [BackStackWorkflow]. + */ +public inline fun backStackWorkflow( + crossinline block: suspend RootScope.() -> Unit +): Workflow> = + object : BackStackWorkflow() { + override suspend fun RootScope.runBackStack() { + block() + } + } + /** * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code - * in [block]. + * in [runBackStack]. * - * [block] can render child workflows by calling [RootScope.showWorkflow]. It can emit outputs to - * its parent by calling [RootScope.emitOutput], and access its props via [RootScope.props]. + * [runBackStack] can render child workflows by calling [RootScope.showWorkflow]. It can emit + * outputs to its parent by calling [RootScope.emitOutput], and access its props via + * [RootScope.props]. * * # Examples * * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: * ``` - * thingyWorkflow { + * backStackWorkflow { * showWorkflow(child1) { * showWorkflow(child2) { * showWorkflow(child3) { @@ -51,7 +64,7 @@ import kotlin.coroutines.resume * * Contrast with calls in series: * ``` - * thingyWorkflow { + * backStackWorkflow { * showWorkflow(child1) { finishWith(Unit) } * showWorkflow(child2) { finishWith(Unit) } * showWorkflow(child3) { } @@ -62,7 +75,7 @@ import kotlin.coroutines.resume * * These can be combined: * ``` - * thingyWorkflow { + * backStackWorkflow { * showWorkflow(child1) { * showWorkflow(child2) { * // goBack(), or @@ -79,19 +92,25 @@ import kotlin.coroutines.resume * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` * again. */ -public fun thingyWorkflow( - block: suspend RootScope.() -> Unit -): Workflow> = ThingyWorkflow(block) +public abstract class BackStackWorkflow : + Workflow> { + + abstract suspend fun RootScope.runBackStack() + + final override fun asStatefulWorkflow(): + StatefulWorkflow> = + BackStackWorkflowImpl(this) +} @DslMarker -annotation class ThingyDsl +annotation class BackStackWorkflowDsl -@ThingyDsl +@BackStackWorkflowDsl public interface RootScope : CoroutineScope { val props: StateFlow /** - * Emits an output to the [thingyWorkflow]'s parent. + * Emits an output to the [backStackWorkflow]'s parent. */ fun emitOutput(output: OutputT) @@ -104,7 +123,7 @@ public interface RootScope : CoroutineScope { * When [onOutput] calls [ShowWorkflowScope.finishWith], this workflow stops rendering, its * rendering is removed from the backstack, and any running output handlers are cancelled. * - * Note that top-level workflows inside a [thingyWorkflow] cannot call + * Note that top-level workflows inside a [backStackWorkflow] cannot call * [ShowWorkflowChildScope.goBack] because the parent doesn't necessarily support that operation. */ suspend fun showWorkflow( @@ -114,11 +133,11 @@ public interface RootScope : CoroutineScope { ): R } -@ThingyDsl +@BackStackWorkflowDsl public sealed interface ShowWorkflowScope : CoroutineScope { /** - * Emits an output to the [thingyWorkflow]'s parent. + * Emits an output to the [backStackWorkflow]'s parent. */ fun emitOutput(output: OutputT) @@ -147,7 +166,7 @@ public sealed interface ShowWorkflowScope : CoroutineScope { ): R } -@ThingyDsl +@BackStackWorkflowDsl public sealed interface ShowWorkflowChildScope : ShowWorkflowScope { /** * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] @@ -187,7 +206,7 @@ public suspend inline fun ShowWorkflowScope<*, *>.showWorkflow( private class RootScopeImpl( override val props: MutableStateFlow, - private val actionSink: Sink>, + private val actionSink: Sink>, coroutineScope: CoroutineScope, ) : RootScope, CoroutineScope by coroutineScope { @@ -210,8 +229,64 @@ private class RootScopeImpl( ) } +private class BackStackWorkflowImpl( + private val workflow: BackStackWorkflow +) : StatefulWorkflow< + PropsT, + BackStackState, + OutputT, + BackStackScreen + >() { + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ): BackStackState { + return BackStackState( + stack = emptyList(), + props = MutableStateFlow(props) + ) + } + + override fun onPropsChanged( + old: PropsT, + new: PropsT, + state: BackStackState + ): BackStackState = state.apply { + props.value = new + } + + override fun render( + renderProps: PropsT, + renderState: BackStackState, + context: RenderContext + ): BackStackScreen { + context.runningSideEffect("main") { + @Suppress("UNCHECKED_CAST") + val scope = RootScopeImpl( + props = renderState.props as MutableStateFlow, + actionSink = context.actionSink, + coroutineScope = this, + ) + with(workflow) { + scope.runBackStack() + } + } + + val renderings = renderState.stack.map { frame -> + @Suppress("UNCHECKED_CAST") + (frame as Frame).renderWorkflow(context) + } + + // TODO show a loading screen if renderings is empty. + return renderings.toBackStackScreen() + } + + override fun snapshotState(state: BackStackState): Snapshot? = null +} + private class ShowWorkflowChildScopeImpl( - private val actionSink: Sink>, + private val actionSink: Sink>, coroutineScope: CoroutineScope, private val onFinish: (R) -> Unit, private val thisFrame: Frame<*, *, *, *, *>, @@ -257,7 +332,7 @@ private class Frame( private val callerJob: Job, val frameScope: CoroutineScope, private val onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, - private val actionSink: Sink>, + private val actionSink: Sink>, private val parent: Frame<*, *, *, *, *>?, ) { private val result = CompletableDeferred(parent = frameScope.coroutineContext.job) @@ -265,7 +340,7 @@ private class Frame( suspend fun awaitResult(): R = result.await() fun renderWorkflow( - context: StatefulWorkflow.RenderContext + context: StatefulWorkflow.RenderContext ): Screen = context.renderChild( child = workflow, props = props, @@ -294,11 +369,11 @@ private class Frame( }) } - private fun onOutput(output: ChildOutputT): WorkflowAction { + private fun onOutput(output: ChildOutputT): WorkflowAction { var canAcceptAction = true - var action: WorkflowAction? = null - val sink = object : Sink> { - override fun send(value: WorkflowAction) { + var action: WorkflowAction? = null + val sink = object : Sink> { + override fun send(value: WorkflowAction) { val sendToSink = synchronized(result) { if (canAcceptAction) { action = value @@ -349,7 +424,7 @@ private suspend fun showWorkflow workflow: Workflow, props: ChildPropsT, onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, - actionSink: Sink>, + actionSink: Sink>, parentFrame: Frame<*, *, *, *, *>?, ): R { val callerContext = currentCoroutineContext() @@ -381,12 +456,12 @@ private suspend fun showWorkflow } } -private class ThingyState( +private class BackStackState( val stack: List>, val props: MutableStateFlow, ) { - fun copy(stack: List> = this.stack) = ThingyState( + fun copy(stack: List> = this.stack) = BackStackState( stack = stack, props = props ) @@ -395,60 +470,6 @@ private class ThingyState( fun removeFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack - frame) } -private class ThingyWorkflow( - private val block: suspend RootScope.() -> Unit -) : StatefulWorkflow< - PropsT, - ThingyState, - OutputT, - BackStackScreen - >() { - - override fun initialState( - props: PropsT, - snapshot: Snapshot? - ): ThingyState { - return ThingyState( - stack = emptyList(), - props = MutableStateFlow(props) - ) - } - - override fun onPropsChanged( - old: PropsT, - new: PropsT, - state: ThingyState - ): ThingyState = state.apply { - props.value = new - } - - override fun render( - renderProps: PropsT, - renderState: ThingyState, - context: RenderContext - ): BackStackScreen { - context.runningSideEffect("main") { - @Suppress("UNCHECKED_CAST") - val scope = RootScopeImpl( - props = renderState.props as MutableStateFlow, - actionSink = context.actionSink, - coroutineScope = this, - ) - block(scope) - } - - val renderings = renderState.stack.map { frame -> - @Suppress("UNCHECKED_CAST") - (frame as Frame).renderWorkflow(context) - } - - // TODO show a loading screen if renderings is empty. - return renderings.toBackStackScreen() - } - - override fun snapshotState(state: ThingyState): Snapshot? = null -} - private suspend fun cancelSelf(): Nothing { val job = currentCoroutineContext().job job.cancel() diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index e49b3c50ce..159b5a0b16 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -16,7 +16,7 @@ fun MyWorkflow( child2: Workflow, child3: Workflow, networkCall: suspend (String) -> String -) = thingyWorkflow { +) = backStackWorkflow { // Step 1 showWorkflow(child1) { output -> From 95ff11b2d47fa4a92664d0b972abdd69b5d5a757 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 09:44:46 -0700 Subject: [PATCH 03/11] cleanup scopes and allow prop updates to children --- .../sample/thingy/BackStackWorkflow.kt | 165 ++++++ .../sample/thingy/BackStackWorkflowImpl.kt | 496 +++++++----------- .../com/squareup/sample/thingy/MyWorkflow.kt | 3 +- 3 files changed, 345 insertions(+), 319 deletions(-) create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt new file mode 100644 index 0000000000..a3409b57f4 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -0,0 +1,165 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf + +/** + * Creates a [BackStackWorkflow]. + */ +public inline fun backStackWorkflow( + crossinline block: suspend BackStackScope.(props: StateFlow) -> Unit +): Workflow> = + object : BackStackWorkflow() { + override suspend fun BackStackScope.runBackStack(props: StateFlow) { + block(props) + } + } + +/** + * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code + * in [runBackStack]. + * + * [runBackStack] can render child workflows by calling [BackStackScope.showWorkflow]. It can emit + * outputs to its parent by calling [BackStackScope.emitOutput], and access its props via + * the parameter passed to [runBackStack]. + * + * # Examples + * + * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * } + * ``` + * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed + * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The + * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that + * called their `showWorkflow`, until the next output is emitted. + * + * Contrast with calls in series: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { finishWith(Unit) } + * showWorkflow(child2) { finishWith(Unit) } + * showWorkflow(child3) { } + * } + * ``` + * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto + * the stack, `child1` will be removed from the stack and replaced with `child2`. + * + * These can be combined: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * // goBack(), or + * finishWith(Unit) + * } + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * ``` + * This code will show `child1` immediately, then when it emits an output show `child2`. When + * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call + * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` + * again. + */ +public abstract class BackStackWorkflow : + Workflow> { + + abstract suspend fun BackStackScope.runBackStack(props: StateFlow) + + final override fun asStatefulWorkflow(): + StatefulWorkflow> = + BackStackWorkflowImpl(this) +} + +@DslMarker +annotation class BackStackWorkflowDsl + +@BackStackWorkflowDsl +public sealed interface BackStackScope : CoroutineScope { + + /** + * Emits an output to the [backStackWorkflow]'s parent. + */ + fun emitOutput(output: OutputT) + + /** + * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. + * + * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call + * doesn't finish before another output is emitted, multiple callbacks can run concurrently. + * + * When [onOutput] calls [BackStackNestedScope.finishWith], this workflow stops rendering, its + * rendering is removed from the backstack, and any running output handlers are cancelled. + * + * When [onOutput] calls [BackStackNestedScope.goBack], if this [showWorkflow] call is nested in + * another, then this workflow will stop rendering, any of its still-running output handlers will + * be cancelled, and the output handler that called this [showWorkflow] will be cancelled. + * If this is a top-level workflow in the [BackStackWorkflow], the whole + * [BackStackWorkflow.runBackStack] is cancelled and restarted, since "back" is only a concept + * that is relevant within a backstack, and it's not possible to know whether the parent supports + * back. What you probably want is to emit an output instead to tell the parent to go back. + * + * @param props The props passed to [workflow] when rendering it. This method will suspend until + * the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] props + * [StateFlow] or using [flowOf]. + */ + suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit + ): R +} + +/** + * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of + * [BackStackScope] with the additional ability to [finish][finishWith] a nested workflow or + * [go back][goBack] to its outer workflow. + */ +@BackStackWorkflowDsl +public sealed interface BackStackNestedScope : BackStackScope { + + /** + * Causes the [showWorkflow] call that ran the output handler that was passed this scope to return + * [value] and cancels any output handlers still running for that workflow. The workflow is + * removed from the stack and will no longer be rendered. + */ + fun finishWith(value: R): Nothing + + /** + * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] + * from the stack, and cancels that parent output handler coroutine (and thus all child workflow + * coroutines as well). + */ + fun goBack(): Nothing +} + +public suspend inline fun BackStackScope.showWorkflow( + workflow: Workflow, + noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit +): R = showWorkflow(workflow, props = flowOf(Unit), onOutput) + +public suspend inline fun BackStackScope<*>.showWorkflow( + workflow: Workflow, + props: Flow, +): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } + +public suspend inline fun BackStackScope<*>.showWorkflow( + workflow: Workflow, +): Nothing = showWorkflow(workflow, props = flowOf(Unit)) { error("Cannot call") } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index 89a5368ea4..87111cbabb 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -1,10 +1,12 @@ package com.squareup.sample.thingy +import com.squareup.workflow1.SessionWorkflow import com.squareup.workflow1.Sink import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.action import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.BackStackScreen @@ -16,222 +18,15 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.job import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -/** - * Creates a [BackStackWorkflow]. - */ -public inline fun backStackWorkflow( - crossinline block: suspend RootScope.() -> Unit -): Workflow> = - object : BackStackWorkflow() { - override suspend fun RootScope.runBackStack() { - block() - } - } - -/** - * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code - * in [runBackStack]. - * - * [runBackStack] can render child workflows by calling [RootScope.showWorkflow]. It can emit - * outputs to its parent by calling [RootScope.emitOutput], and access its props via - * [RootScope.props]. - * - * # Examples - * - * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { - * showWorkflow(child2) { - * showWorkflow(child3) { - * // goBack() - * } - * } - * } - * } - * ``` - * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed - * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The - * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that - * called their `showWorkflow`, until the next output is emitted. - * - * Contrast with calls in series: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { finishWith(Unit) } - * showWorkflow(child2) { finishWith(Unit) } - * showWorkflow(child3) { } - * } - * ``` - * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto - * the stack, `child1` will be removed from the stack and replaced with `child2`. - * - * These can be combined: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { - * showWorkflow(child2) { - * // goBack(), or - * finishWith(Unit) - * } - * showWorkflow(child3) { - * // goBack() - * } - * } - * } - * ``` - * This code will show `child1` immediately, then when it emits an output show `child2`. When - * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call - * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` - * again. - */ -public abstract class BackStackWorkflow : - Workflow> { - - abstract suspend fun RootScope.runBackStack() - - final override fun asStatefulWorkflow(): - StatefulWorkflow> = - BackStackWorkflowImpl(this) -} - -@DslMarker -annotation class BackStackWorkflowDsl - -@BackStackWorkflowDsl -public interface RootScope : CoroutineScope { - val props: StateFlow - - /** - * Emits an output to the [backStackWorkflow]'s parent. - */ - fun emitOutput(output: OutputT) - - /** - * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. - * - * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call - * doesn't finish before another output is emitted, multiple callbacks can run concurrently. - * - * When [onOutput] calls [ShowWorkflowScope.finishWith], this workflow stops rendering, its - * rendering is removed from the backstack, and any running output handlers are cancelled. - * - * Note that top-level workflows inside a [backStackWorkflow] cannot call - * [ShowWorkflowChildScope.goBack] because the parent doesn't necessarily support that operation. - */ - suspend fun showWorkflow( - workflow: Workflow, - props: ChildPropsT, - onOutput: suspend ShowWorkflowScope.(output: ChildOutputT) -> Unit - ): R -} - -@BackStackWorkflowDsl -public sealed interface ShowWorkflowScope : CoroutineScope { - - /** - * Emits an output to the [backStackWorkflow]'s parent. - */ - fun emitOutput(output: OutputT) - - /** - * Causes the [showWorkflow] call that ran the output handler that was passed this scope to return - * [value] and cancels any output handlers still running for that workflow. The workflow is - * removed from the stack and will no longer be rendered. - */ - suspend fun finishWith(value: R): Nothing - - /** - * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. - * - * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call - * doesn't finish before another output is emitted, multiple callbacks can run concurrently. - * - * When [onOutput] calls [ShowWorkflowScope.finishWith] or [ShowWorkflowChildScope.goBack], this - * workflow stops rendering, its rendering is removed from the backstack, and any running output - * handlers are cancelled. [ShowWorkflowChildScope.goBack] will also cancel the output handler - * of the parent workflow that called this [showWorkflow]. - */ - suspend fun showWorkflow( - workflow: Workflow, - props: ChildPropsT, - onOutput: suspend ShowWorkflowChildScope.(output: ChildOutputT) -> Unit - ): R -} - -@BackStackWorkflowDsl -public sealed interface ShowWorkflowChildScope : ShowWorkflowScope { - /** - * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] - * from the stack, and cancels that parent output handler coroutine (and thus all child workflow - * coroutines as well). - */ - suspend fun goBack(): Nothing -} - -public suspend inline fun RootScope<*, OutputT>.showWorkflow( - workflow: Workflow, - noinline onOutput: suspend ShowWorkflowScope.(output: ChildOutputT) -> Unit -): R = showWorkflow(workflow, props = Unit, onOutput) - -public suspend inline fun RootScope<*, *>.showWorkflow( - workflow: Workflow, - props: ChildPropsT, -): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } - -public suspend inline fun RootScope<*, *>.showWorkflow( - workflow: Workflow, -): Nothing = showWorkflow(workflow, props = Unit) { error("Cannot call") } - -public suspend inline fun ShowWorkflowScope.showWorkflow( - workflow: Workflow, - noinline onOutput: suspend ShowWorkflowChildScope.(output: ChildOutputT) -> Unit -): R = showWorkflow(workflow, props = Unit, onOutput) -public suspend inline fun ShowWorkflowScope<*, *>.showWorkflow( - workflow: Workflow, - props: ChildPropsT, -): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } - -public suspend inline fun ShowWorkflowScope<*, *>.showWorkflow( - workflow: Workflow, -): Nothing = showWorkflow(workflow, props = Unit) { error("Cannot call") } - -private class RootScopeImpl( - override val props: MutableStateFlow, - private val actionSink: Sink>, - coroutineScope: CoroutineScope, -) : RootScope, CoroutineScope by coroutineScope { - - override fun emitOutput(output: OutputT) { - actionSink.send(action("emitOutput") { - setOutput(output) - }) - } - - override suspend fun showWorkflow( - workflow: Workflow, - props: ChildPropsT, - onOutput: suspend ShowWorkflowScope.(ChildOutputT) -> Unit - ): R = showWorkflow( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = null - ) -} - -private class BackStackWorkflowImpl( +@OptIn(WorkflowExperimentalApi::class) +internal class BackStackWorkflowImpl( private val workflow: BackStackWorkflow -) : StatefulWorkflow< +) : SessionWorkflow< PropsT, BackStackState, OutputT, @@ -240,12 +35,31 @@ private class BackStackWorkflowImpl( override fun initialState( props: PropsT, - snapshot: Snapshot? + snapshot: Snapshot?, + workflowScope: CoroutineScope ): BackStackState { - return BackStackState( + val propsFlow = MutableStateFlow(props) + + @Suppress("UNCHECKED_CAST") + val initialState = BackStackState( stack = emptyList(), - props = MutableStateFlow(props) + props = propsFlow as MutableStateFlow ) + + // TODO move this into the launch call so the scope is correct (use this instead of + // workflowScope). + val scope = BackStackScopeImpl( + coroutineScope = workflowScope, + ) + workflowScope.launch(start = CoroutineStart.UNDISPATCHED) { + with(workflow) { + scope.runBackStack(propsFlow) + } + } + + // TODO gather initial state from the coroutine + + return initialState } override fun onPropsChanged( @@ -254,6 +68,7 @@ private class BackStackWorkflowImpl( state: BackStackState ): BackStackState = state.apply { props.value = new + // TODO gather updated state from coroutine } override fun render( @@ -261,17 +76,6 @@ private class BackStackWorkflowImpl( renderState: BackStackState, context: RenderContext ): BackStackScreen { - context.runningSideEffect("main") { - @Suppress("UNCHECKED_CAST") - val scope = RootScopeImpl( - props = renderState.props as MutableStateFlow, - actionSink = context.actionSink, - coroutineScope = this, - ) - with(workflow) { - scope.runBackStack() - } - } val renderings = renderState.stack.map { frame -> @Suppress("UNCHECKED_CAST") @@ -285,13 +89,36 @@ private class BackStackWorkflowImpl( override fun snapshotState(state: BackStackState): Snapshot? = null } -private class ShowWorkflowChildScopeImpl( +internal class BackStackScopeImpl( + coroutineScope: CoroutineScope, +) : BackStackScope, CoroutineScope by coroutineScope { + lateinit var actionSink: Sink> + + override fun emitOutput(output: OutputT) { + actionSink.send(action("emitOutput") { + setOutput(output) + }) + } + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit + ): R = showWorkflow( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = null + ) +} + +private class BackStackNestedScopeImpl( private val actionSink: Sink>, coroutineScope: CoroutineScope, - private val onFinish: (R) -> Unit, - private val thisFrame: Frame<*, *, *, *, *>, + private val thisFrame: Frame<*, *, *, *, R>, private val parentFrame: Frame<*, *, *, *, *>?, -) : ShowWorkflowChildScope, CoroutineScope by coroutineScope { +) : BackStackNestedScope, CoroutineScope by coroutineScope { override fun emitOutput(output: OutputT) { actionSink.send(action("emitOutput") { @@ -302,8 +129,8 @@ private class ShowWorkflowChildScopeImpl( @Suppress("UNCHECKED_CAST") override suspend fun showWorkflow( workflow: Workflow, - props: ChildPropsT, - onOutput: suspend ShowWorkflowChildScope.(ChildOutputT) -> Unit + props: Flow, + onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit ): R = showWorkflow( workflow = workflow, props = props, @@ -312,33 +139,83 @@ private class ShowWorkflowChildScopeImpl( parentFrame = thisFrame, ) - override suspend fun finishWith(value: R): Nothing { - onFinish(value) - cancelSelf() + override fun finishWith(value: R): Nothing { + // TODO figure out how to coalesce this action into the one for showWorkflow. WorkStealingDispatcher? + actionSink.send(action("finishFrame") { + state = state.removeFrame(thisFrame) + }) + thisFrame.finishWith(value) } - override suspend fun goBack(): Nothing { + override fun goBack(): Nothing { // If parent is null, goBack will not be exposed and will never be called. val parent = checkNotNull(parentFrame) { "goBack called on root scope" } - parent.popTo() - - cancelSelf() + actionSink.send(action("popTo") { + state = state.popToFrame(parent) + }) + thisFrame.cancelSelf() } } -private class Frame( +internal class Frame private constructor( private val workflow: Workflow, private val props: ChildPropsT, private val callerJob: Job, val frameScope: CoroutineScope, - private val onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, + private val onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, private val actionSink: Sink>, private val parent: Frame<*, *, *, *, *>?, + private val result: CompletableDeferred, ) { - private val result = CompletableDeferred(parent = frameScope.coroutineContext.job) + constructor( + workflow: Workflow, + initialProps: ChildPropsT, + callerJob: Job, + frameScope: CoroutineScope, + onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, + actionSink: Sink>, + parent: Frame<*, *, *, *, *>?, + ) : this( + workflow = workflow, + props = initialProps, + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parent, + result = CompletableDeferred(parent = frameScope.coroutineContext.job) + ) + + fun copy( + props: ChildPropsT = this.props, + ): Frame = Frame( + workflow = workflow, + props = props, + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parent, + result = result + ) suspend fun awaitResult(): R = result.await() + fun cancelCaller() { + callerJob.cancel() + } + + fun finishWith(value: R): Nothing { + result.complete(value) + cancelSelf() + } + + fun cancelSelf(): Nothing { + frameScope.cancel() + frameScope.ensureActive() + error("Nonsense") + } + fun renderWorkflow( context: StatefulWorkflow.RenderContext ): Screen = context.renderChild( @@ -347,28 +224,6 @@ private class Frame( handler = ::onOutput ) - /** - * Pops everything off the stack that comes after this. - */ - fun popTo() { - actionSink.send(action("popTo") { - val stack = state.stack - val index = stack.indexOf(this@Frame) - check(index != -1) { "Frame was not in the stack!" } - - // Cancel all the frames we're about to drop, starting from the top. - for (i in stack.lastIndex downTo index + 1) { - // Don't just cancel the frame job, since that would only cancel output handlers the frame - // is running. We want to cancel the whole parent's output handler that called showWorkflow, - // in case the showWorkflow is in a try/catch that tries to make other suspending calls. - stack[i].callerJob.cancel() - } - - val newStack = stack.take(index + 1) - state = state.copy(stack = newStack) - }) - } - private fun onOutput(output: ChildOutputT): WorkflowAction { var canAcceptAction = true var action: WorkflowAction? = null @@ -393,17 +248,9 @@ private class Frame( // either call showWorkflow, finishWith, or goBack, and so then we can just return that action // immediately instead of needing a whole separate render pass. frameScope.launch(start = CoroutineStart.UNDISPATCHED) { - val showScope = ShowWorkflowChildScopeImpl( - sink, + val showScope = BackStackNestedScopeImpl( + actionSink = sink, coroutineScope = this, - onFinish = { - // This will eventually cancel the frame scope. - result.complete(it) - // TODO figure out how to coalesce this action into the one for showWorkflow. WorkStealingDispatcher? - sink.send(action("unshowWorkflow") { - state = state.removeFrame(this@Frame) - }) - }, thisFrame = this@Frame, parentFrame = parent ) @@ -419,11 +266,10 @@ private class Frame( } // TODO concurrent calls to this function on the same scope should cancel/remove prior calls. -// Or maybe just enforce the same thing as Flow, only allow calls from the same job? private suspend fun showWorkflow( workflow: Workflow, - props: ChildPropsT, - onOutput: suspend ShowWorkflowChildScopeImpl.(ChildOutputT) -> Unit, + props: Flow, + onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, actionSink: Sink>, parentFrame: Frame<*, *, *, *, *>?, ): R { @@ -432,19 +278,38 @@ private suspend fun showWorkflow val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) lateinit var frame: Frame + val initialProps = CompletableDeferred() + val readyForPropUpdates = Job() + frameScope.launch { + props.collect { newProps -> + if (initialProps.isActive) { + initialProps.complete(newProps) + } else { + // Ensure the frame has actually been added to the stack. + readyForPropUpdates.join() + actionSink.send(action("setProps") { + state = state.setFrameProps(frame, newProps) + }) + } + } + } + frame = Frame( + workflow = workflow, + initialProps = initialProps.await(), + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parentFrame, + ) + // Tell the workflow runtime to start rendering the new workflow. - actionSink.sendAndAwaitApplication(action("showWorkflow") { - frame = Frame( - workflow = workflow, - props = props, - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parentFrame, - ) + actionSink.send(action("showWorkflow") { state = state.appendFrame(frame) }) + // Allow the props collector to send more prop update actions. Even though the initial action + // hasn't run yet, any future actions will be enqueued after it, so it's safe. + readyForPropUpdates.complete() return try { frame.awaitResult() @@ -456,7 +321,7 @@ private suspend fun showWorkflow } } -private class BackStackState( +internal class BackStackState( val stack: List>, val props: MutableStateFlow, ) { @@ -468,39 +333,34 @@ private class BackStackState( fun appendFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack + frame) fun removeFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack - frame) -} -private suspend fun cancelSelf(): Nothing { - val job = currentCoroutineContext().job - job.cancel() - job.ensureActive() - error("Nonsense") -} + fun popToFrame(frame: Frame<*, *, *, *, *>): BackStackState { + val index = stack.indexOf(frame) + check(index != -1) { "Frame was not in the stack!" } -private suspend fun < - PropsT, - StateT, - OutputT - > Sink>.sendAndAwaitApplication( - action: WorkflowAction -) { - suspendCancellableCoroutine { continuation -> - val resumingAction = object : WorkflowAction() { - // Pipe through debugging name to the original action. - override val debuggingName: String - get() = action.debuggingName - - override fun Updater.apply() { - // Don't execute anything if the caller was cancelled while we were in the queue. - if (!continuation.isActive) return - - with(action) { - // Forward our Updater to the real action. - apply() - } - continuation.resume(Unit) - } + // Cancel all the frames we're about to drop, starting from the top. + for (i in stack.lastIndex downTo index + 1) { + // Don't just cancel the frame job, since that would only cancel output handlers the frame + // is running. We want to cancel the whole parent's output handler that called showWorkflow, + // in case the showWorkflow is in a try/catch that tries to make other suspending calls. + stack[i].cancelCaller() + } + + val newStack = stack.take(index + 1) + return copy(stack = newStack) + } + + fun setFrameProps( + frame: Frame<*, *, ChildPropsT, *, *>, + newProps: ChildPropsT + ): BackStackState { + val stack = stack.toMutableList() + val myIndex = stack.indexOf(frame) + if (myIndex == -1) { + // Frame has been removed from the stack, so just no-op. + return this } - send(resumingAction) + stack[myIndex] = frame.copy(props = newProps) + return copy(stack = stack) } } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index 159b5a0b16..c4632a1fcb 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -3,6 +3,7 @@ package com.squareup.sample.thingy import com.squareup.workflow1.Workflow import com.squareup.workflow1.ui.Screen import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @@ -42,7 +43,7 @@ fun MyWorkflow( delay(3.seconds) emitOutput(MyOutputs.Done) } - showWorkflow(child3, networkResult) {} + showWorkflow(child3, flowOf(networkResult)) {} } else -> {} From 2a7bf427d645591476da332e851d1ffabff4f7db Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 10:49:52 -0700 Subject: [PATCH 04/11] support showing raw screens --- .../sample/thingy/BackStackWorkflow.kt | 130 +++++++------ .../sample/thingy/BackStackWorkflowImpl.kt | 180 +++++++++++++++--- .../com/squareup/sample/thingy/MyWorkflow.kt | 2 +- 3 files changed, 223 insertions(+), 89 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index a3409b57f4..da328570c2 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf /** - * Creates a [BackStackWorkflow]. + * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more + * information about what [block] can do. */ public inline fun backStackWorkflow( crossinline block: suspend BackStackScope.(props: StateFlow) -> Unit @@ -25,62 +26,65 @@ public inline fun backStackWorkflow( * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code * in [runBackStack]. * - * [runBackStack] can render child workflows by calling [BackStackScope.showWorkflow]. It can emit - * outputs to its parent by calling [BackStackScope.emitOutput], and access its props via - * the parameter passed to [runBackStack]. - * - * # Examples - * - * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { - * showWorkflow(child2) { - * showWorkflow(child3) { - * // goBack() - * } - * } - * } - * } - * ``` - * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed - * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The - * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that - * called their `showWorkflow`, until the next output is emitted. - * - * Contrast with calls in series: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { finishWith(Unit) } - * showWorkflow(child2) { finishWith(Unit) } - * showWorkflow(child3) { } - * } - * ``` - * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto - * the stack, `child1` will be removed from the stack and replaced with `child2`. - * - * These can be combined: - * ``` - * backStackWorkflow { - * showWorkflow(child1) { - * showWorkflow(child2) { - * // goBack(), or - * finishWith(Unit) - * } - * showWorkflow(child3) { - * // goBack() - * } - * } - * } - * ``` - * This code will show `child1` immediately, then when it emits an output show `child2`. When - * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call - * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` - * again. + * [runBackStack] can show renderings and render child workflows, as well as emit outputs to this + * workflow's parent. See the docs on that method for more info. */ public abstract class BackStackWorkflow : Workflow> { + /** + * Show renderings by calling [BackStackScope.showScreen]. Show child workflows by calling + * [BackStackScope.showWorkflow]. Emit outputs by calling [BackStackScope.emitOutput]. + * + * # Examples + * + * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * } + * ``` + * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed + * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The + * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that + * called their `showWorkflow`, until the next output is emitted. + * + * Contrast with calls in series: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { finishWith(Unit) } + * showWorkflow(child2) { finishWith(Unit) } + * showWorkflow(child3) { } + * } + * ``` + * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto + * the stack, `child1` will be removed from the stack and replaced with `child2`. + * + * These can be combined: + * ``` + * backStackWorkflow { + * showWorkflow(child1) { + * showWorkflow(child2) { + * // goBack(), or + * finishWith(Unit) + * } + * showWorkflow(child3) { + * // goBack() + * } + * } + * } + * ``` + * This code will show `child1` immediately, then when it emits an output show `child2`. When + * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call + * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` + * again. + */ abstract suspend fun BackStackScope.runBackStack(props: StateFlow) final override fun asStatefulWorkflow(): @@ -116,15 +120,25 @@ public sealed interface BackStackScope : CoroutineScope { * that is relevant within a backstack, and it's not possible to know whether the parent supports * back. What you probably want is to emit an output instead to tell the parent to go back. * - * @param props The props passed to [workflow] when rendering it. This method will suspend until - * the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] props - * [StateFlow] or using [flowOf]. + * @param props The props passed to [workflow] when rendering it. [showWorkflow] will suspend + * until the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] + * props [StateFlow] or using [flowOf]. */ suspend fun showWorkflow( workflow: Workflow, + // TODO revert this back to a single value – can use the same trick to update props as for + // emitting new screens. props: Flow, onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit ): R + + /** + * Shows the screen produced by [screenFactory]. Suspends until [BackStackNestedScope.finishWith] + * or [BackStackNestedScope.goBack] is called. + */ + suspend fun showScreen( + screenFactory: BackStackNestedScope.() -> Screen + ): R } /** @@ -140,14 +154,14 @@ public sealed interface BackStackNestedScope : BackStackScope BackStackScope.showWorkflow( diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index 87111cbabb..c1175f8d3b 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -67,7 +67,7 @@ internal class BackStackWorkflowImpl( new: PropsT, state: BackStackState ): BackStackState = state.apply { - props.value = new + setProps(new) // TODO gather updated state from coroutine } @@ -77,9 +77,17 @@ internal class BackStackWorkflowImpl( context: RenderContext ): BackStackScreen { - val renderings = renderState.stack.map { frame -> - @Suppress("UNCHECKED_CAST") - (frame as Frame).renderWorkflow(context) + val renderings = renderState.mapFrames { frame -> + when (frame) { + is WorkflowFrame<*, *, *, *, *> -> { + @Suppress("UNCHECKED_CAST") + (frame as WorkflowFrame).renderWorkflow(context) + } + + is ScreenFrame<*, *> -> { + frame.screen + } + } } // TODO show a loading screen if renderings is empty. @@ -111,13 +119,21 @@ internal class BackStackScopeImpl( actionSink = actionSink, parentFrame = null ) + + override suspend fun showScreen( + screenFactory: BackStackNestedScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink, + parentFrame = null + ) } private class BackStackNestedScopeImpl( private val actionSink: Sink>, coroutineScope: CoroutineScope, - private val thisFrame: Frame<*, *, *, *, R>, - private val parentFrame: Frame<*, *, *, *, *>?, + private val thisFrame: Frame, + private val parentFrame: Frame<*>?, ) : BackStackNestedScope, CoroutineScope by coroutineScope { override fun emitOutput(output: OutputT) { @@ -139,7 +155,16 @@ private class BackStackNestedScopeImpl( parentFrame = thisFrame, ) - override fun finishWith(value: R): Nothing { + @Suppress("UNCHECKED_CAST") + override suspend fun showScreen( + screenFactory: BackStackNestedScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink as Sink>, + parentFrame = thisFrame, + ) + + override suspend fun finishWith(value: R): Nothing { // TODO figure out how to coalesce this action into the one for showWorkflow. WorkStealingDispatcher? actionSink.send(action("finishFrame") { state = state.removeFrame(thisFrame) @@ -147,7 +172,7 @@ private class BackStackNestedScopeImpl( thisFrame.finishWith(value) } - override fun goBack(): Nothing { + override suspend fun goBack(): Nothing { // If parent is null, goBack will not be exposed and will never be called. val parent = checkNotNull(parentFrame) { "goBack called on root scope" } actionSink.send(action("popTo") { @@ -157,16 +182,27 @@ private class BackStackNestedScopeImpl( } } -internal class Frame private constructor( +internal sealed interface Frame { + fun cancelCaller() + suspend fun awaitResult(): R + suspend fun finishWith(value: R): Nothing + suspend fun cancelSelf(): Nothing +} + +/** + * Represents a call to [BackStackScope.showWorkflow]. + */ +internal class WorkflowFrame private constructor( private val workflow: Workflow, private val props: ChildPropsT, private val callerJob: Job, - val frameScope: CoroutineScope, + private val frameScope: CoroutineScope, private val onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, private val actionSink: Sink>, - private val parent: Frame<*, *, *, *, *>?, + private val parent: Frame<*>?, private val result: CompletableDeferred, -) { +) : Frame { + constructor( workflow: Workflow, initialProps: ChildPropsT, @@ -174,7 +210,7 @@ internal class Frame private cons frameScope: CoroutineScope, onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, actionSink: Sink>, - parent: Frame<*, *, *, *, *>?, + parent: Frame<*>?, ) : this( workflow = workflow, props = initialProps, @@ -188,7 +224,7 @@ internal class Frame private cons fun copy( props: ChildPropsT = this.props, - ): Frame = Frame( + ): WorkflowFrame = WorkflowFrame( workflow = workflow, props = props, callerJob = callerJob, @@ -199,20 +235,22 @@ internal class Frame private cons result = result ) - suspend fun awaitResult(): R = result.await() + override suspend fun awaitResult(): R = result.await() - fun cancelCaller() { + override fun cancelCaller() { callerJob.cancel() } - fun finishWith(value: R): Nothing { + override suspend fun finishWith(value: R): Nothing { result.complete(value) cancelSelf() } - fun cancelSelf(): Nothing { + override suspend fun cancelSelf(): Nothing { frameScope.cancel() - frameScope.ensureActive() + val currentContext = currentCoroutineContext() + currentContext.cancel() + currentContext.ensureActive() error("Nonsense") } @@ -251,7 +289,7 @@ internal class Frame private cons val showScope = BackStackNestedScopeImpl( actionSink = sink, coroutineScope = this, - thisFrame = this@Frame, + thisFrame = this@WorkflowFrame, parentFrame = parent ) onOutput(showScope, output) @@ -265,18 +303,62 @@ internal class Frame private cons } } +/** + * Represents a call to [BackStackScope.showScreen]. + */ +internal class ScreenFrame( + private val callerJob: Job, + private val frameScope: CoroutineScope, + private val actionSink: Sink>, + private val parent: Frame<*>?, +) : Frame { + private val result = CompletableDeferred() + + lateinit var screen: Screen + private set + + fun initScreen(screenFactory: BackStackNestedScope.() -> Screen) { + val factoryScope = BackStackNestedScopeImpl( + actionSink = actionSink, + coroutineScope = frameScope, + thisFrame = this, + parentFrame = parent + ) + screen = screenFactory(factoryScope) + } + + override suspend fun awaitResult(): R = result.await() + + override fun cancelCaller() { + callerJob.cancel() + } + + override suspend fun finishWith(value: R): Nothing { + result.complete(value) + cancelSelf() + } + + override suspend fun cancelSelf(): Nothing { + frameScope.cancel() + val currentContext = currentCoroutineContext() + currentContext.cancel() + currentContext.ensureActive() + error("Nonsense") + } +} + // TODO concurrent calls to this function on the same scope should cancel/remove prior calls. private suspend fun showWorkflow( workflow: Workflow, props: Flow, onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, actionSink: Sink>, - parentFrame: Frame<*, *, *, *, *>?, + parentFrame: Frame<*>?, ): R { val callerContext = currentCoroutineContext() val callerJob = callerContext.job val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) - lateinit var frame: Frame + lateinit var frame: WorkflowFrame val initialProps = CompletableDeferred() val readyForPropUpdates = Job() @@ -293,7 +375,7 @@ private suspend fun showWorkflow } } } - frame = Frame( + frame = WorkflowFrame( workflow = workflow, initialProps = initialProps.await(), callerJob = callerJob, @@ -321,20 +403,56 @@ private suspend fun showWorkflow } } +private suspend fun showScreenImpl( + screenFactory: BackStackNestedScope.() -> Screen, + actionSink: Sink>, + parentFrame: Frame<*>?, +): R { + val callerContext = currentCoroutineContext() + val callerJob = callerContext.job + val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) + + val frame = ScreenFrame( + callerJob = callerJob, + frameScope = frameScope, + actionSink = actionSink, + parent = parentFrame, + ) + frame.initScreen(screenFactory) + + // Tell the workflow runtime to start rendering the new workflow. + actionSink.send(action("showScreen") { + state = state.appendFrame(frame) + }) + + return try { + frame.awaitResult() + } finally { + frameScope.cancel() + actionSink.send(action("unshowScreen") { + state = state.removeFrame(frame) + }) + } +} + internal class BackStackState( - val stack: List>, - val props: MutableStateFlow, + private val stack: List>, + private val props: MutableStateFlow, ) { - fun copy(stack: List> = this.stack) = BackStackState( + fun copy(stack: List> = this.stack) = BackStackState( stack = stack, props = props ) - fun appendFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack + frame) - fun removeFrame(frame: Frame<*, *, *, *, *>) = copy(stack = stack - frame) + fun setProps(props: Any?) { + this.props.value = props + } + + fun appendFrame(frame: Frame<*>) = copy(stack = stack + frame) + fun removeFrame(frame: Frame<*>) = copy(stack = stack - frame) - fun popToFrame(frame: Frame<*, *, *, *, *>): BackStackState { + fun popToFrame(frame: Frame<*>): BackStackState { val index = stack.indexOf(frame) check(index != -1) { "Frame was not in the stack!" } @@ -351,7 +469,7 @@ internal class BackStackState( } fun setFrameProps( - frame: Frame<*, *, ChildPropsT, *, *>, + frame: WorkflowFrame<*, *, ChildPropsT, *, *>, newProps: ChildPropsT ): BackStackState { val stack = stack.toMutableList() @@ -363,4 +481,6 @@ internal class BackStackState( stack[myIndex] = frame.copy(props = newProps) return copy(stack = stack) } + + inline fun mapFrames(block: (Frame<*>) -> R): List = stack.map(block) } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index c4632a1fcb..a77e72f4cd 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -43,7 +43,7 @@ fun MyWorkflow( delay(3.seconds) emitOutput(MyOutputs.Done) } - showWorkflow(child3, flowOf(networkResult)) {} + showWorkflow(child3, flowOf(networkResult)) } else -> {} From 4198e39b0b5d7d0887e6eef8da17efbed13ab879 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 10:57:30 -0700 Subject: [PATCH 05/11] wip --- .../sample/thingy/BackStackWorkflow.kt | 9 ++- .../com/squareup/sample/thingy/MyWorkflow.kt | 62 ++++++++++--------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index da328570c2..d22f3450b6 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf +import kotlin.experimental.ExperimentalTypeInference /** * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more @@ -164,11 +165,17 @@ public sealed interface BackStackNestedScope : BackStackScope BackStackScope.showWorkflow( workflow: Workflow, - noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit + @BuilderInference noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit ): R = showWorkflow(workflow, props = flowOf(Unit), onOutput) +// public suspend inline fun BackStackScope.showWorkflow( +// workflow: Workflow, +// noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit +// ): Unit = showWorkflow(workflow, props = flowOf(Unit), onOutput) + public suspend inline fun BackStackScope<*>.showWorkflow( workflow: Workflow, props: Flow, diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index a77e72f4cd..aeeea1e35d 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -3,6 +3,7 @@ package com.squareup.sample.thingy import com.squareup.workflow1.Workflow import com.squareup.workflow1.ui.Screen import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @@ -12,41 +13,44 @@ enum class MyOutputs { Done, } -fun MyWorkflow( - child1: Workflow, - child2: Workflow, - child3: Workflow, - networkCall: suspend (String) -> String -) = backStackWorkflow { +class MyWorkflow( + private val child1: Workflow, + private val child2: Workflow, + private val child3: Workflow, + private val networkCall: suspend (String) -> String +) : BackStackWorkflow() { - // Step 1 - showWorkflow(child1) { output -> - when (output) { - "back" -> emitOutput(MyOutputs.Back) - "next" -> { - // Step 2 - val childResult = showWorkflow(child2) { output -> - if (output == "back") { - // Removes child2 from the stack, cancels the output handler from step 1, and just - // leaves child1 rendering. - goBack() - } else { - finishWith(output) + override suspend fun BackStackScope.runBackStack(props: StateFlow) { + // Step 1 + // TODO clean this up + val ignored: Unit = showWorkflow(child1) { output -> + when (output) { + "back" -> emitOutput(MyOutputs.Back) + "next" -> { + // Step 2 + val childResult = showWorkflow(child2) { output -> + if (output == "back") { + // Removes child2 from the stack, cancels the output handler from step 1, and just + // leaves child1 rendering. + goBack() + } else { + finishWith(output) + } } - } - // TODO: Show a loading screen automatically. - val networkResult = networkCall(childResult) + // TODO: Show a loading screen automatically. + val networkResult = networkCall(childResult) - // Step 3: Show a workflow for 3 seconds then finish. - launch { - delay(3.seconds) - emitOutput(MyOutputs.Done) + // Step 3: Show a workflow for 3 seconds then finish. + launch { + delay(3.seconds) + emitOutput(MyOutputs.Done) + } + showWorkflow(child3, flowOf(networkResult)) } - showWorkflow(child3, flowOf(networkResult)) - } - else -> {} + else -> {} + } } } } From fd73a0bb45f5f72987b2332b5b3b34828542f6e3 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 11:40:26 -0700 Subject: [PATCH 06/11] reshape the api to infer types better --- .../sample/thingy/BackStackWorkflow.kt | 77 ++++++------ .../sample/thingy/BackStackWorkflowImpl.kt | 117 ++++++++++++------ .../com/squareup/sample/thingy/MyWorkflow.kt | 49 ++++++-- 3 files changed, 151 insertions(+), 92 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index d22f3450b6..6572a764b0 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -8,18 +8,23 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf -import kotlin.experimental.ExperimentalTypeInference /** * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more * information about what [block] can do. */ public inline fun backStackWorkflow( - crossinline block: suspend BackStackScope.(props: StateFlow) -> Unit + crossinline block: suspend BackStackScope.( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) -> Unit ): Workflow> = object : BackStackWorkflow() { - override suspend fun BackStackScope.runBackStack(props: StateFlow) { - block(props) + override suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) { + block(props, emitOutput) } } @@ -35,7 +40,7 @@ public abstract class BackStackWorkflow : /** * Show renderings by calling [BackStackScope.showScreen]. Show child workflows by calling - * [BackStackScope.showWorkflow]. Emit outputs by calling [BackStackScope.emitOutput]. + * [BackStackScope.showWorkflow]. Emit outputs by calling [emitOutput]. * * # Examples * @@ -86,7 +91,10 @@ public abstract class BackStackWorkflow : * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` * again. */ - abstract suspend fun BackStackScope.runBackStack(props: StateFlow) + abstract suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (OutputT) -> Unit + ) final override fun asStatefulWorkflow(): StatefulWorkflow> = @@ -97,12 +105,7 @@ public abstract class BackStackWorkflow : annotation class BackStackWorkflowDsl @BackStackWorkflowDsl -public sealed interface BackStackScope : CoroutineScope { - - /** - * Emits an output to the [backStackWorkflow]'s parent. - */ - fun emitOutput(output: OutputT) +public sealed interface BackStackParentScope { /** * Starts rendering [workflow] and pushes its rendering onto the top of the backstack. @@ -110,10 +113,11 @@ public sealed interface BackStackScope : CoroutineScope { * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call * doesn't finish before another output is emitted, multiple callbacks can run concurrently. * - * When [onOutput] calls [BackStackNestedScope.finishWith], this workflow stops rendering, its - * rendering is removed from the backstack, and any running output handlers are cancelled. + * When [onOutput] returns a value, this workflow stops rendering, its rendering is removed from + * the backstack, and any running output handlers are cancelled. The calling coroutine is resumed + * with the value. * - * When [onOutput] calls [BackStackNestedScope.goBack], if this [showWorkflow] call is nested in + * When [onOutput] calls [BackStackWorkflowScope.goBack], if this [showWorkflow] call is nested in * another, then this workflow will stop rendering, any of its still-running output handlers will * be cancelled, and the output handler that called this [showWorkflow] will be cancelled. * If this is a top-level workflow in the [BackStackWorkflow], the whole @@ -130,32 +134,27 @@ public sealed interface BackStackScope : CoroutineScope { // TODO revert this back to a single value – can use the same trick to update props as for // emitting new screens. props: Flow, - onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit + onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R ): R /** - * Shows the screen produced by [screenFactory]. Suspends until [BackStackNestedScope.finishWith] - * or [BackStackNestedScope.goBack] is called. + * Shows the screen produced by [screenFactory]. Suspends untilBackStackNestedScope.goBack] is + * called. */ suspend fun showScreen( - screenFactory: BackStackNestedScope.() -> Screen + screenFactory: BackStackScreenScope.() -> Screen ): R } +@BackStackWorkflowDsl +public sealed interface BackStackScope : BackStackParentScope, CoroutineScope + /** * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of - * [BackStackScope] with the additional ability to [finish][finishWith] a nested workflow or - * [go back][goBack] to its outer workflow. + * [BackStackScope] with the additional ability to [go back][goBack] to its outer workflow. */ @BackStackWorkflowDsl -public sealed interface BackStackNestedScope : BackStackScope { - - /** - * Causes the [showWorkflow] call that ran the output handler that was passed this scope to return - * [value] and cancels any output handlers still running for that workflow. The workflow is - * removed from the stack and will no longer be rendered. - */ - suspend fun finishWith(value: R): Nothing +public sealed interface BackStackWorkflowScope : BackStackScope { /** * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] @@ -165,22 +164,22 @@ public sealed interface BackStackNestedScope : BackStackScope BackStackScope.showWorkflow( +@BackStackWorkflowDsl +public sealed interface BackStackScreenScope : BackStackScope { + fun continueWith(value: R) + fun goBack() +} + +public suspend inline fun BackStackParentScope.showWorkflow( workflow: Workflow, - @BuilderInference noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit + noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R ): R = showWorkflow(workflow, props = flowOf(Unit), onOutput) -// public suspend inline fun BackStackScope.showWorkflow( -// workflow: Workflow, -// noinline onOutput: suspend BackStackNestedScope.(output: ChildOutputT) -> Unit -// ): Unit = showWorkflow(workflow, props = flowOf(Unit), onOutput) - -public suspend inline fun BackStackScope<*>.showWorkflow( +public suspend inline fun BackStackParentScope.showWorkflow( workflow: Workflow, props: Flow, ): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } -public suspend inline fun BackStackScope<*>.showWorkflow( +public suspend inline fun BackStackParentScope.showWorkflow( workflow: Workflow, ): Nothing = showWorkflow(workflow, props = flowOf(Unit)) { error("Cannot call") } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index c1175f8d3b..f66043accb 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -53,7 +53,11 @@ internal class BackStackWorkflowImpl( ) workflowScope.launch(start = CoroutineStart.UNDISPATCHED) { with(workflow) { - scope.runBackStack(propsFlow) + scope.runBackStack(propsFlow, emitOutput = { output -> + @Suppress("UNCHECKED_CAST") + (scope.actionSink as Sink>) + .send(action("emitOutput") { setOutput(output) }) + }) } } @@ -76,7 +80,6 @@ internal class BackStackWorkflowImpl( renderState: BackStackState, context: RenderContext ): BackStackScreen { - val renderings = renderState.mapFrames { frame -> when (frame) { is WorkflowFrame<*, *, *, *, *> -> { @@ -99,19 +102,14 @@ internal class BackStackWorkflowImpl( internal class BackStackScopeImpl( coroutineScope: CoroutineScope, -) : BackStackScope, CoroutineScope by coroutineScope { +) : BackStackScope, CoroutineScope by coroutineScope { + // TODO set this lateinit var actionSink: Sink> - override fun emitOutput(output: OutputT) { - actionSink.send(action("emitOutput") { - setOutput(output) - }) - } - override suspend fun showWorkflow( workflow: Workflow, props: Flow, - onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R ): R = showWorkflow( workflow = workflow, props = props, @@ -121,7 +119,7 @@ internal class BackStackScopeImpl( ) override suspend fun showScreen( - screenFactory: BackStackNestedScope.() -> Screen + screenFactory: BackStackScreenScope.() -> Screen ): R = showScreenImpl( screenFactory = screenFactory, actionSink = actionSink, @@ -129,24 +127,57 @@ internal class BackStackScopeImpl( ) } -private class BackStackNestedScopeImpl( +private class BackStackWorkflowScopeImpl( private val actionSink: Sink>, coroutineScope: CoroutineScope, - private val thisFrame: Frame, + private val thisFrame: WorkflowFrame<*, *, *, *, R>, private val parentFrame: Frame<*>?, -) : BackStackNestedScope, CoroutineScope by coroutineScope { +) : BackStackWorkflowScope, CoroutineScope by coroutineScope { - override fun emitOutput(output: OutputT) { - actionSink.send(action("emitOutput") { - setOutput(output) + @Suppress("UNCHECKED_CAST") + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R + ): R = showWorkflow( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = thisFrame, + ) + + @Suppress("UNCHECKED_CAST") + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink as Sink>, + parentFrame = thisFrame, + ) + + override suspend fun goBack(): Nothing { + // If parent is null, goBack will not be exposed and will never be called. + val parent = checkNotNull(parentFrame) { "goBack called on root scope" } + actionSink.send(action("popTo") { + state = state.popToFrame(parent) }) + thisFrame.cancelSelf() } +} + +private class BackStackScreenScopeImpl( + private val actionSink: Sink>, + coroutineScope: CoroutineScope, + private val thisFrame: ScreenFrame, + private val parentFrame: Frame<*>?, +) : BackStackScreenScope, CoroutineScope by coroutineScope { @Suppress("UNCHECKED_CAST") override suspend fun showWorkflow( workflow: Workflow, props: Flow, - onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R ): R = showWorkflow( workflow = workflow, props = props, @@ -157,36 +188,32 @@ private class BackStackNestedScopeImpl( @Suppress("UNCHECKED_CAST") override suspend fun showScreen( - screenFactory: BackStackNestedScope.() -> Screen + screenFactory: BackStackScreenScope.() -> Screen ): R = showScreenImpl( screenFactory = screenFactory, actionSink = actionSink as Sink>, parentFrame = thisFrame, ) - override suspend fun finishWith(value: R): Nothing { - // TODO figure out how to coalesce this action into the one for showWorkflow. WorkStealingDispatcher? - actionSink.send(action("finishFrame") { - state = state.removeFrame(thisFrame) - }) - thisFrame.finishWith(value) + override fun continueWith(value: R) { + thisFrame.continueWith(value) } - override suspend fun goBack(): Nothing { + override fun goBack() { // If parent is null, goBack will not be exposed and will never be called. val parent = checkNotNull(parentFrame) { "goBack called on root scope" } actionSink.send(action("popTo") { state = state.popToFrame(parent) }) - thisFrame.cancelSelf() + thisFrame.cancel() } } internal sealed interface Frame { fun cancelCaller() suspend fun awaitResult(): R - suspend fun finishWith(value: R): Nothing suspend fun cancelSelf(): Nothing + fun cancel() } /** @@ -197,7 +224,7 @@ internal class WorkflowFrame priv private val props: ChildPropsT, private val callerJob: Job, private val frameScope: CoroutineScope, - private val onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, + private val onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, private val actionSink: Sink>, private val parent: Frame<*>?, private val result: CompletableDeferred, @@ -208,7 +235,7 @@ internal class WorkflowFrame priv initialProps: ChildPropsT, callerJob: Job, frameScope: CoroutineScope, - onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, actionSink: Sink>, parent: Frame<*>?, ) : this( @@ -241,19 +268,23 @@ internal class WorkflowFrame priv callerJob.cancel() } - override suspend fun finishWith(value: R): Nothing { + suspend fun finishWith(value: R): Nothing { result.complete(value) cancelSelf() } override suspend fun cancelSelf(): Nothing { - frameScope.cancel() + cancel() val currentContext = currentCoroutineContext() currentContext.cancel() currentContext.ensureActive() error("Nonsense") } + override fun cancel() { + frameScope.cancel() + } + fun renderWorkflow( context: StatefulWorkflow.RenderContext ): Screen = context.renderChild( @@ -286,13 +317,13 @@ internal class WorkflowFrame priv // either call showWorkflow, finishWith, or goBack, and so then we can just return that action // immediately instead of needing a whole separate render pass. frameScope.launch(start = CoroutineStart.UNDISPATCHED) { - val showScope = BackStackNestedScopeImpl( + val showScope = BackStackWorkflowScopeImpl( actionSink = sink, coroutineScope = this, thisFrame = this@WorkflowFrame, parentFrame = parent ) - onOutput(showScope, output) + finishWith(onOutput(showScope, output)) } // Once the coroutine has suspended, all sends must go to the real sink. @@ -317,8 +348,8 @@ internal class ScreenFrame( lateinit var screen: Screen private set - fun initScreen(screenFactory: BackStackNestedScope.() -> Screen) { - val factoryScope = BackStackNestedScopeImpl( + fun initScreen(screenFactory: BackStackScreenScope.() -> Screen) { + val factoryScope = BackStackScreenScopeImpl( actionSink = actionSink, coroutineScope = frameScope, thisFrame = this, @@ -333,25 +364,29 @@ internal class ScreenFrame( callerJob.cancel() } - override suspend fun finishWith(value: R): Nothing { + fun continueWith(value: R) { result.complete(value) - cancelSelf() + cancel() } override suspend fun cancelSelf(): Nothing { - frameScope.cancel() + cancel() val currentContext = currentCoroutineContext() currentContext.cancel() currentContext.ensureActive() error("Nonsense") } + + override fun cancel() { + frameScope.cancel() + } } // TODO concurrent calls to this function on the same scope should cancel/remove prior calls. private suspend fun showWorkflow( workflow: Workflow, props: Flow, - onOutput: suspend BackStackNestedScope.(ChildOutputT) -> Unit, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, actionSink: Sink>, parentFrame: Frame<*>?, ): R { @@ -404,7 +439,7 @@ private suspend fun showWorkflow } private suspend fun showScreenImpl( - screenFactory: BackStackNestedScope.() -> Screen, + screenFactory: BackStackScreenScope.() -> Screen, actionSink: Sink>, parentFrame: Frame<*>?, ): R { diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index aeeea1e35d..aacc22c18f 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -13,6 +13,12 @@ enum class MyOutputs { Done, } +data class RetryScreen( + val message: String, + val onRetryClicked: () -> Unit, + val onCancelClicked: () -> Unit, +) : Screen + class MyWorkflow( private val child1: Workflow, private val child2: Workflow, @@ -20,28 +26,28 @@ class MyWorkflow( private val networkCall: suspend (String) -> String ) : BackStackWorkflow() { - override suspend fun BackStackScope.runBackStack(props: StateFlow) { + override suspend fun BackStackScope.runBackStack( + props: StateFlow, + emitOutput: (MyOutputs) -> Unit + ) { // Step 1 - // TODO clean this up - val ignored: Unit = showWorkflow(child1) { output -> + showWorkflow(child1) { output -> when (output) { "back" -> emitOutput(MyOutputs.Back) "next" -> { // Step 2 val childResult = showWorkflow(child2) { output -> - if (output == "back") { // Removes child2 from the stack, cancels the output handler from step 1, and just // leaves child1 rendering. - goBack() - } else { - finishWith(output) - } + if (output == "back") goBack() + output } - // TODO: Show a loading screen automatically. - val networkResult = networkCall(childResult) + // Step 3 – make a network call, showing a retry screen if it fails. If the user cancels + // instead of retrying, we go back to showing child1. + val networkResult = networkCallWithRetry(childResult) - // Step 3: Show a workflow for 3 seconds then finish. + // Step 4: Show a workflow for 3 seconds then finish. launch { delay(3.seconds) emitOutput(MyOutputs.Done) @@ -49,8 +55,27 @@ class MyWorkflow( showWorkflow(child3, flowOf(networkResult)) } - else -> {} + else -> error("Unexpected output: $output") + } + } + } + + private suspend fun BackStackParentScope.networkCallWithRetry( + request: String + ): String { + // TODO: Show a loading screen automatically. + var networkResult = networkCall(request) + while (networkResult == "failure") { + showScreen { + RetryScreen( + message = networkResult, + onRetryClicked = { continueWith(Unit) }, + // Go back to showing child1. + onCancelClicked = { goBack() } + ) } + networkResult = networkCall(request) } + return networkResult } } From 702e06f9c58bd509c8d35f62ab879cac1d1b65dd Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 11:56:56 -0700 Subject: [PATCH 07/11] added idle screen support for empty stack --- .../squareup/sample/thingy/BackStackWorkflow.kt | 15 ++++++++++++--- .../sample/thingy/BackStackWorkflowImpl.kt | 7 +++++-- .../java/com/squareup/sample/thingy/MyWorkflow.kt | 4 ++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index 6572a764b0..ce009d8ebd 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -11,10 +11,11 @@ import kotlinx.coroutines.flow.flowOf /** * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more - * information about what [block] can do. + * information about what [runBackStack] can do. */ public inline fun backStackWorkflow( - crossinline block: suspend BackStackScope.( + crossinline createIdleScreen: () -> Screen, + crossinline runBackStack: suspend BackStackScope.( props: StateFlow, emitOutput: (OutputT) -> Unit ) -> Unit @@ -24,8 +25,10 @@ public inline fun backStackWorkflow( props: StateFlow, emitOutput: (OutputT) -> Unit ) { - block(props, emitOutput) + runBackStack(props, emitOutput) } + + override fun createIdleScreen(): Screen = createIdleScreen() } /** @@ -96,6 +99,12 @@ public abstract class BackStackWorkflow : emitOutput: (OutputT) -> Unit ) + /** + * Called to provide a screen to display when [runBackStack] has not shown anything yet, or when + * a workflow's output handler is idle (not showing an active screen). + */ + abstract fun createIdleScreen(): Screen + final override fun asStatefulWorkflow(): StatefulWorkflow> = BackStackWorkflowImpl(this) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index f66043accb..2a1f118074 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -93,8 +93,11 @@ internal class BackStackWorkflowImpl( } } - // TODO show a loading screen if renderings is empty. - return renderings.toBackStackScreen() + return if (renderings.isEmpty()) { + BackStackScreen(workflow.createIdleScreen()) + } else { + renderings.toBackStackScreen() + } } override fun snapshotState(state: BackStackState): Snapshot? = null diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index aacc22c18f..f7ebf7cdae 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -19,6 +19,8 @@ data class RetryScreen( val onCancelClicked: () -> Unit, ) : Screen +data object LoadingScreen : Screen + class MyWorkflow( private val child1: Workflow, private val child2: Workflow, @@ -60,6 +62,8 @@ class MyWorkflow( } } + override fun createIdleScreen(): Screen = LoadingScreen + private suspend fun BackStackParentScope.networkCallWithRetry( request: String ): String { From 1a5bdeb7dc072c12500128ec51cc073d68a44bf1 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 28 Aug 2025 12:41:49 -0700 Subject: [PATCH 08/11] moar docs so i don't forget some cool usage patterns --- .../sample/thingy/BackStackWorkflow.kt | 139 +++++++++++++++++- 1 file changed, 135 insertions(+), 4 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index ce009d8ebd..9189a28f53 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch /** * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more @@ -45,11 +46,58 @@ public abstract class BackStackWorkflow : * Show renderings by calling [BackStackScope.showScreen]. Show child workflows by calling * [BackStackScope.showWorkflow]. Emit outputs by calling [emitOutput]. * - * # Examples + * # Showing a screen + * + * ``` + * backStackWorkflow { _, _ -> + * // Suspends until continueWith is called. + * val result = showScreen { + * MyScreenClass( + * // Returns "finished" from showScreen. + * onDoneClicked = { continueWith("finished") }, + * ) + * } + * } + * ``` + * + * # Showing a workflow + * + * ``` + * backStackWorkflow { _, _ -> + * // Suspends until an onOutput lambda returns a value. + * val result = showWorkflow( + * childWorkflow, + * props = flowOf(childProps) + * onOutput = { output -> + * // Returns "finished: …" from showWorkflow. + * return@showWorkflow "finished: $output" + * } + * ) + * } + * ``` + * + * # Emitting output + * + * The second parameter to the [runBackStack] function is an [emitOutput] function that will send + * whatever you pass to it to this workflow's parent as an output. + * ``` + * backStackWorkflow { _, emitOutput -> + * showWorkflow( + * childWorkflow, + * props = flowOf(childProps) + * onOutput = { output -> + * // Forward the output to parent. + * emitOutput(output) + * } + * ) + * } + * ``` + * + * # Nested vs serial calls * * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example: * ``` - * backStackWorkflow { + * backStackWorkflow { _, _ -> * showWorkflow(child1) { * showWorkflow(child2) { * showWorkflow(child3) { @@ -66,7 +114,7 @@ public abstract class BackStackWorkflow : * * Contrast with calls in series: * ``` - * backStackWorkflow { + * backStackWorkflow { _, _ -> * showWorkflow(child1) { finishWith(Unit) } * showWorkflow(child2) { finishWith(Unit) } * showWorkflow(child3) { } @@ -77,7 +125,7 @@ public abstract class BackStackWorkflow : * * These can be combined: * ``` - * backStackWorkflow { + * backStackWorkflow { _, _ -> * showWorkflow(child1) { * showWorkflow(child2) { * // goBack(), or @@ -93,6 +141,77 @@ public abstract class BackStackWorkflow : * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child` * again. + * + * To push another screen on the backstack from a non-workflow screen, [launch] a coroutine: + * ``` + * backStackScreen { _, _ -> + * showScreen { + * MyScreen( + * onEvent = { + * launch { + * showWorkflow(…) + * } + * } + * } + * } + * } + * ``` + * + * # Cancelling screens + * + * Calling [BackStackScope.showScreen] or [BackStackScope.showWorkflow] suspends the caller until + * that workflow/screen produces a result. They handle coroutine cancellation too: if the calling + * coroutine is cancelled while they're showing, they are removed from the backstack. + * + * This can be used to, for example, update a screen based on a flow: + * ``` + * backStackWorkflow { props, _ -> + * props.collectLatest { prop -> + * showScreen { + * MyScreen(message = prop) + * } + * } + * } + * ``` + * This example shows the props received from the parent to the user via `MyScreen`. Every time + * the parent passes a new props, the `showScreen` call is cancelled and called again with the + * new props, replacing the old instance of `MyScreen` in the backstack with a new one. Since + * both instances of `MyScreen` are compatible, this is not a navigation event but just updates + * the `MyScreen` view factory. + * + * # Factoring out code + * + * You don't have to keep all the logic for your backstack in a single function. You can pull out + * functions, just make them extensions on [BackStackParentScope] to get access to `showScreen` + * and `showRendering` calls. + * + * E.g. here's a helper that performs some suspending task and shows a retry screen if it fails: + * ``` + * suspend fun BackStackParentScope.userRetriable( + * action: suspend () -> R + * ): R { + * var result = runCatching { action() } + * // runCatching can catch CancellationException, so check. + * ensureActive() + * + * while (result.isFailure) { + * showScreen { + * RetryScreen( + * message = "Failed: ${result.exceptionOrNull()}", + * onRetryClicked = { continueWith(Unit) }, + * onCancelClicked = { goBack() } + * ) + * } + * + * // Try again. + * result = runCatching { action() } + * ensureActive() + * } + * + * // We only leave the loop when action succeeded. + * return result.getOrThrow() + * } + * ``` */ abstract suspend fun BackStackScope.runBackStack( props: StateFlow, @@ -134,6 +253,12 @@ public sealed interface BackStackParentScope { * that is relevant within a backstack, and it's not possible to know whether the parent supports * back. What you probably want is to emit an output instead to tell the parent to go back. * + * If the coroutine calling [showWorkflow] is cancelled, the workflow stops being rendered and its + * rendering will be removed from the backstack. + * + * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method + * to implement a backstack workflow. + * * @param props The props passed to [workflow] when rendering it. [showWorkflow] will suspend * until the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] * props [StateFlow] or using [flowOf]. @@ -149,6 +274,12 @@ public sealed interface BackStackParentScope { /** * Shows the screen produced by [screenFactory]. Suspends untilBackStackNestedScope.goBack] is * called. + * + * If the coroutine calling [showScreen] is cancelled, the rendering will be removed from the + * backstack. + * + * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method + * to implement a backstack workflow. */ suspend fun showScreen( screenFactory: BackStackScreenScope.() -> Screen From 739fbead0ba4073457ab44c9b42964fbda15d7c5 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Fri, 29 Aug 2025 12:41:04 -0700 Subject: [PATCH 09/11] split up into multiple files, wrote design for better state management --- .../sample/thingy/BackStackFactory.kt | 65 +++ .../squareup/sample/thingy/BackStackFrame.kt | 191 ++++++++ .../sample/thingy/BackStackScopeImpl.kt | 35 ++ .../sample/thingy/BackStackScreenScopeImpl.kt | 51 ++ .../squareup/sample/thingy/BackStackState.kt | 145 ++++++ .../sample/thingy/BackStackWorkflow.kt | 53 ++- .../sample/thingy/BackStackWorkflowImpl.kt | 441 +----------------- .../thingy/BackStackWorkflowScopeImpl.kt | 47 ++ .../com/squareup/sample/thingy/MyWorkflow.kt | 9 +- .../com/squareup/sample/thingy/ShowHelpers.kt | 108 +++++ 10 files changed, 690 insertions(+), 455 deletions(-) create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt new file mode 100644 index 0000000000..61b95c3968 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt @@ -0,0 +1,65 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +public fun interface BackStackFactory { + + /** + * Responsible for converting a list of [Screen]s into a [BackStackScreen]. This function *must* + * handle the case where [screens] is empty, since [BackStackScreen] must always have at least + * one screen. It *should* handle the case where [isTopIdle] is true, which indicates that the + * top (last) screen in [screens] is doing some work that may eventually show another screen. + * + * @see toBackStackScreen + */ + fun createBackStack( + screens: List, + isTopIdle: Boolean + ): BackStackScreen + + companion object { + internal val ThrowOnIdle + get() = showLoadingScreen { + error("No BackStackFactory provided") + } + + /** + * Returns a [BackStackFactory] that shows a [loading screen][createLoadingScreen] when + * [BackStackWorkflow.runBackStack] has not shown anything yet or when a workflow's output + * handler is idle (not showing an active screen). + */ + fun showLoadingScreen( + name: String = "", + createLoadingScreen: () -> Screen + ): BackStackFactory = BackStackFactory { screens, isTopIdle -> + val mutableScreens = screens.toMutableList() + if (mutableScreens.isEmpty() || isTopIdle) { + mutableScreens += createLoadingScreen() + } + mutableScreens.toBackStackScreen(name) + } + } +} + +/** + * Returns a [CoroutineContext.Element] that will store this [BackStackFactory] in a + * [CoroutineContext] to later be retrieved by [backStackFactory]. + */ +public fun BackStackFactory.asContextElement(): CoroutineContext.Element = + BackStackFactoryContextElement(this) + +/** + * Looks for a [BackStackFactory] stored the current context via [asContextElement]. + */ +public val CoroutineContext.backStackFactory: BackStackFactory? + get() = this[BackStackFactoryContextElement]?.factory + +private class BackStackFactoryContextElement( + val factory: BackStackFactory +) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt new file mode 100644 index 0000000000..52e31643f5 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt @@ -0,0 +1,191 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowAction.Companion +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +internal sealed interface BackStackFrame { + fun cancelCaller() + suspend fun awaitResult(): R + suspend fun cancelSelf(): Nothing + fun cancel() +} + +/** + * Represents a call to [BackStackScope.showWorkflow]. + */ +internal class WorkflowFrame private constructor( + private val workflow: Workflow, + private val props: ChildPropsT, + private val callerJob: Job, + private val frameScope: CoroutineScope, + private val onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, + private val actionSink: Sink>, + private val parent: BackStackFrame<*>?, + private val result: CompletableDeferred, +) : BackStackFrame { + + constructor( + workflow: Workflow, + initialProps: ChildPropsT, + callerJob: Job, + frameScope: CoroutineScope, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, + actionSink: Sink>, + parent: BackStackFrame<*>?, + ) : this( + workflow = workflow, + props = initialProps, + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parent, + result = CompletableDeferred(parent = frameScope.coroutineContext.job) + ) + + fun copy( + props: ChildPropsT = this.props, + ): WorkflowFrame = WorkflowFrame( + workflow = workflow, + props = props, + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parent, + result = result + ) + + override suspend fun awaitResult(): R = result.await() + + override fun cancelCaller() { + callerJob.cancel() + } + + private suspend fun finishWith(value: R): Nothing { + result.complete(value) + cancelSelf() + } + + override suspend fun cancelSelf(): Nothing { + cancel() + val currentContext = currentCoroutineContext() + currentContext.cancel() + currentContext.ensureActive() + error("Nonsense") + } + + override fun cancel() { + frameScope.cancel() + } + + fun renderWorkflow( + context: RenderContext + ): Screen = context.renderChild( + child = workflow, + props = props, + handler = ::onOutput + ) + + private fun onOutput(output: ChildOutputT): WorkflowAction { + var canAcceptAction = true + var action: WorkflowAction? = null + val sink = object : Sink> { + override fun send(value: WorkflowAction) { + val sendToSink = synchronized(result) { + if (canAcceptAction) { + action = value + canAcceptAction = false + false + } else { + true + } + } + if (sendToSink) { + actionSink.send(value) + } + } + } + + // Run synchronously until first suspension point since in many cases it will immediately + // either call showWorkflow, finishWith, or goBack, and so then we can just return that action + // immediately instead of needing a whole separate render pass. + frameScope.launch(start = UNDISPATCHED) { + val showScope = BackStackWorkflowScopeImpl( + actionSink = sink, + coroutineScope = this, + thisFrame = this@WorkflowFrame, + parentFrame = parent + ) + finishWith(onOutput(showScope, output)) + } + // TODO collect WorkflowAction + + // Once the coroutine has suspended, all sends must go to the real sink. + return synchronized(result) { + canAcceptAction = false + action ?: WorkflowAction.noAction() + } + } +} + +/** + * Represents a call to [BackStackScope.showScreen]. + */ +internal class ScreenFrame( + private val callerJob: Job, + private val frameScope: CoroutineScope, + private val actionSink: Sink>, + private val parent: BackStackFrame<*>?, +) : BackStackFrame { + private val result = CompletableDeferred() + + lateinit var screen: Screen + private set + + fun initScreen(screenFactory: BackStackScreenScope.() -> Screen) { + val factoryScope = BackStackScreenScopeImpl( + actionSink = actionSink, + coroutineScope = frameScope, + thisFrame = this, + parentFrame = parent + ) + screen = screenFactory(factoryScope) + } + + override suspend fun awaitResult(): R = result.await() + + override fun cancelCaller() { + callerJob.cancel() + } + + fun continueWith(value: R) { + result.complete(value) + cancel() + } + + override suspend fun cancelSelf(): Nothing { + cancel() + val currentContext = currentCoroutineContext() + currentContext.cancel() + currentContext.ensureActive() + error("Nonsense") + } + + override fun cancel() { + frameScope.cancel() + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt new file mode 100644 index 0000000000..51174ff3f2 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt @@ -0,0 +1,35 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +internal class BackStackScopeImpl( + coroutineScope: CoroutineScope, +) : BackStackScope, CoroutineScope by coroutineScope { + // TODO set this + lateinit var actionSink: Sink> + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R + ): R = showWorkflowImpl( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = null + ) + + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink, + parentFrame = null + ) +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt new file mode 100644 index 0000000000..9605a35b9f --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt @@ -0,0 +1,51 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +internal class BackStackScreenScopeImpl( + private val actionSink: Sink>, + coroutineScope: CoroutineScope, + private val thisFrame: ScreenFrame, + private val parentFrame: BackStackFrame<*>?, +) : BackStackScreenScope, CoroutineScope by coroutineScope { + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R + ): R = showWorkflowImpl( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = thisFrame, + ) + + @Suppress("UNCHECKED_CAST") + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink as Sink>, + parentFrame = thisFrame, + ) + + override fun continueWith(value: R) { + thisFrame.continueWith(value) + } + + override fun cancelScreen() { + // If parent is null, goBack will not be exposed and will never be called. + val parent = checkNotNull(parentFrame) { "goBack called on root scope" } + actionSink.send(action("popTo") { + state = state.popToFrame(parent) + }) + thisFrame.cancel() + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt new file mode 100644 index 0000000000..4eb70bd35c --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt @@ -0,0 +1,145 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.navigation.BackStackScreen +import kotlinx.coroutines.flow.MutableStateFlow + +/* +TODO: Design for coalescing state updates/output emissions and dispatching + -------------------------------------------------------------------- + + Currently when something shows a thing, it creates a WorkflowAction and sends it to the action + sink directly. onOutput does one trick to capture any emitOutput called from the UNDISPATCHED + launch, but this is brittle and only handles some cases. And since these actions are sent in + multiple places—i.e. replacing a workflow sends two actions: one to remove the frame when + cancelled and one to add the new frame. + + To fix this, there is a two-part solution. + + First, actions should never be sent directly. Instead, we need a special queue object that + accepts two things: + - (State) -> State functions. These are used by show*Impl calls to update the stack. + - Output values. These are sent only by emitOutput. + + This queue can then be used to collect all queued state transformations into a single action, + along with the first-emitted output, if any. The BackStackWorkflowImpl can produce an action for + the queue whenever it needs to: + 1. In initialState, after launching the coroutine, to get the initial state for the first render + call and return from initialState. + 2. In onPropsUpdated, to collect any state changes effected by pushing the new props value into + the flow and return it from onPropsUpdated. + 3. In a workflow's outputHandler, to collect the immediate set of updates generated by the + output handler and return an action to bubble up the action cascade. + + The second part is a special CoroutineDispatcher, similar to WorkStealingDispatcher, that can be + drained at any time. The API (in addition to basic CoroutineDispatcher stuff) should look + something like this: + + internal class BackStackDispatcher: CoroutineDispatcher() { + … + + /** + * Suspends indefinitely, handling any dispatch calls that aren't inside a [runThenDrain] by + * dispatching to the dispatcher from the current context. After processing at least one task, + * when there are no more tasks enqueued, calls [onIdle]. + */ + suspend fun run(onIdle: () -> Unit) + + /** + * Runs [block] such that any tasks that are dispatched to this dispatcher by [block] are not + * dispatched like normal, but collected into a special queue and all ran after [block] returns + * but before this function returns. I.e. any coroutine work started by [block] is guaranteed to + * be have been run and the dispatcher will be idle when this function returns. + */ + fun runThenDrain(block: () -> Unit) + } + + This dispatcher can then be used inside the BackStackWorkflowImpl functions mentioned above to + ensure all coroutines run before collecting state transformations. E.g. + + override fun initialState(…): BackStackState { + dispatcher.runThenDrain { + scope.launch { runBackStack(…) } + } + val initialStack = actionQueue.consumeAllStateTransformations() + … + return BackStackState(frames = initialStack, dispatcher = dispatcher, actions = actionQueue) + } + + Inside render, `run` can be used to support normal dispatching: + + context.runningSideEffect { + state.dispatcher.run(onIdle = { + // Only returns >1 action if multiple emitOutput calls happened. All state transformations + // will always be in the first one. Returns an empty list if no state transforms or outputs + // were enqueued. + val actions = state.actionQueue.consumeAsActions() + actions.forEach { + context.actionSink.send(it) + } + }) + } + + To ensure all show*Impl calls get processed by this idle handler, they need to always internally + make sure they're running on the special dispatcher. + + All this dispatcher/action queue coordination should be encapsulated inside the BackStackState. +*/ + +// Impl note: Does some casting to avoid dealing with generics everywhere, since this is internal- +// only. Can we use this trick in more places? +internal class BackStackState( + private val stack: List>, + private val props: MutableStateFlow, + private val backStackFactory: BackStackFactory, +) { + + fun copy(stack: List> = this.stack) = BackStackState( + stack = stack, + props = props, + backStackFactory = backStackFactory, + ) + + fun setProps(props: Any?) { + this.props.value = props + } + + fun appendFrame(frame: BackStackFrame<*>) = copy(stack = stack + frame) + fun removeFrame(frame: BackStackFrame<*>) = copy(stack = stack - frame) + + fun popToFrame(frame: BackStackFrame<*>): BackStackState { + val index = stack.indexOf(frame) + check(index != -1) { "Frame was not in the stack!" } + + // Cancel all the frames we're about to drop, starting from the top. + for (i in stack.lastIndex downTo index + 1) { + // Don't just cancel the frame job, since that would only cancel output handlers the frame + // is running. We want to cancel the whole parent's output handler that called showWorkflow, + // in case the showWorkflow is in a try/catch that tries to make other suspending calls. + stack[i].cancelCaller() + } + + val newStack = stack.take(index + 1) + return copy(stack = newStack) + } + + fun setFrameProps( + frame: WorkflowFrame<*, *, ChildPropsT, *, *>, + newProps: ChildPropsT + ): BackStackState { + val stack = stack.toMutableList() + val myIndex = stack.indexOf(frame) + if (myIndex == -1) { + // Frame has been removed from the stack, so just no-op. + return this + } + stack[myIndex] = frame.copy(props = newProps) + return copy(stack = stack) + } + + inline fun mapFrames(block: (BackStackFrame<*>) -> R): List = stack.map(block) + + fun createBackStack(screens: List): BackStackScreen = + // TODO pass isTopIdle + backStackFactory.createBackStack(screens, isTopIdle = false) +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index 9189a28f53..0ac9312352 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -9,13 +9,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext /** - * Creates a [BackStackWorkflow]. See the docs on [BackStackWorkflow.runBackStack] for more - * information about what [runBackStack] can do. + * Creates a [BackStackWorkflow]. + * + * @param getBackStackFactory See [BackStackWorkflow.getBackStackFactory]. If null, the default + * implementation is used. + * @param runBackStack See [BackStackWorkflow.runBackStack]. */ public inline fun backStackWorkflow( - crossinline createIdleScreen: () -> Screen, + noinline getBackStackFactory: ((CoroutineContext) -> BackStackFactory)? = null, crossinline runBackStack: suspend BackStackScope.( props: StateFlow, emitOutput: (OutputT) -> Unit @@ -29,7 +33,12 @@ public inline fun backStackWorkflow( runBackStack(props, emitOutput) } - override fun createIdleScreen(): Screen = createIdleScreen() + override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + if (getBackStackFactory != null) { + getBackStackFactory(coroutineContext) + } else { + super.getBackStackFactory(coroutineContext) + } } /** @@ -219,10 +228,15 @@ public abstract class BackStackWorkflow : ) /** - * Called to provide a screen to display when [runBackStack] has not shown anything yet, or when - * a workflow's output handler is idle (not showing an active screen). + * Return a [BackStackFactory] used to convert the stack of screens produced by this workflow to + * a [BackStackScreen]. + * + * The default implementation tries to find a [BackStackFactory] passed to the workflow runtime + * via its [CoroutineScope], and if that fails, returns an implementation that will throw whenever + * the stack is empty or the top screen is idle. */ - abstract fun createIdleScreen(): Screen + open fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + coroutineContext.backStackFactory ?: BackStackFactory.ThrowOnIdle final override fun asStatefulWorkflow(): StatefulWorkflow> = @@ -245,21 +259,21 @@ public sealed interface BackStackParentScope { * the backstack, and any running output handlers are cancelled. The calling coroutine is resumed * with the value. * - * When [onOutput] calls [BackStackWorkflowScope.goBack], if this [showWorkflow] call is nested in + * When [onOutput] calls [BackStackWorkflowScope.cancelWorkflow], if this [showWorkflowImpl] call is nested in * another, then this workflow will stop rendering, any of its still-running output handlers will - * be cancelled, and the output handler that called this [showWorkflow] will be cancelled. + * be cancelled, and the output handler that called this [showWorkflowImpl] will be cancelled. * If this is a top-level workflow in the [BackStackWorkflow], the whole * [BackStackWorkflow.runBackStack] is cancelled and restarted, since "back" is only a concept * that is relevant within a backstack, and it's not possible to know whether the parent supports * back. What you probably want is to emit an output instead to tell the parent to go back. * - * If the coroutine calling [showWorkflow] is cancelled, the workflow stops being rendered and its + * If the coroutine calling [showWorkflowImpl] is cancelled, the workflow stops being rendered and its * rendering will be removed from the backstack. * * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method * to implement a backstack workflow. * - * @param props The props passed to [workflow] when rendering it. [showWorkflow] will suspend + * @param props The props passed to [workflow] when rendering it. [showWorkflowImpl] will suspend * until the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack] * props [StateFlow] or using [flowOf]. */ @@ -291,23 +305,32 @@ public sealed interface BackStackScope : BackStackParentScope, CoroutineScope /** * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of - * [BackStackScope] with the additional ability to [go back][goBack] to its outer workflow. + * [BackStackScope] with the additional ability to [go back][cancelWorkflow] to its outer workflow. */ @BackStackWorkflowDsl public sealed interface BackStackWorkflowScope : BackStackScope { /** - * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflow] + * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflowImpl] * from the stack, and cancels that parent output handler coroutine (and thus all child workflow * coroutines as well). */ - suspend fun goBack(): Nothing + suspend fun cancelWorkflow(): Nothing } +/** + * Scope receiver used for all [showScreen] calls. This has all the capabilities of + * [BackStackScope] with the additional ability to [go back][cancelScreen] to its outer workflow and + * to return from [showScreen] by calling [continueWith]. + */ @BackStackWorkflowDsl public sealed interface BackStackScreenScope : BackStackScope { + /** + * Causes [showScreen] to return with [value]. + */ fun continueWith(value: R) - fun goBack() + + fun cancelScreen() } public suspend inline fun BackStackParentScope.showWorkflow( diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index 2a1f118074..d197ddee84 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -3,24 +3,14 @@ package com.squareup.sample.thingy import com.squareup.workflow1.SessionWorkflow import com.squareup.workflow1.Sink import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.action import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.BackStackScreen -import com.squareup.workflow1.ui.navigation.toBackStackScreen -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.job import kotlinx.coroutines.launch @OptIn(WorkflowExperimentalApi::class) @@ -39,11 +29,13 @@ internal class BackStackWorkflowImpl( workflowScope: CoroutineScope ): BackStackState { val propsFlow = MutableStateFlow(props) + val backStackFactory = workflow.getBackStackFactory(workflowScope.coroutineContext) @Suppress("UNCHECKED_CAST") val initialState = BackStackState( stack = emptyList(), - props = propsFlow as MutableStateFlow + props = propsFlow as MutableStateFlow, + backStackFactory = backStackFactory, ) // TODO move this into the launch call so the scope is correct (use this instead of @@ -92,433 +84,8 @@ internal class BackStackWorkflowImpl( } } } - - return if (renderings.isEmpty()) { - BackStackScreen(workflow.createIdleScreen()) - } else { - renderings.toBackStackScreen() - } + return renderState.createBackStack(renderings) } override fun snapshotState(state: BackStackState): Snapshot? = null } - -internal class BackStackScopeImpl( - coroutineScope: CoroutineScope, -) : BackStackScope, CoroutineScope by coroutineScope { - // TODO set this - lateinit var actionSink: Sink> - - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflow( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = null - ) - - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink, - parentFrame = null - ) -} - -private class BackStackWorkflowScopeImpl( - private val actionSink: Sink>, - coroutineScope: CoroutineScope, - private val thisFrame: WorkflowFrame<*, *, *, *, R>, - private val parentFrame: Frame<*>?, -) : BackStackWorkflowScope, CoroutineScope by coroutineScope { - - @Suppress("UNCHECKED_CAST") - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflow( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = thisFrame, - ) - - @Suppress("UNCHECKED_CAST") - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink as Sink>, - parentFrame = thisFrame, - ) - - override suspend fun goBack(): Nothing { - // If parent is null, goBack will not be exposed and will never be called. - val parent = checkNotNull(parentFrame) { "goBack called on root scope" } - actionSink.send(action("popTo") { - state = state.popToFrame(parent) - }) - thisFrame.cancelSelf() - } -} - -private class BackStackScreenScopeImpl( - private val actionSink: Sink>, - coroutineScope: CoroutineScope, - private val thisFrame: ScreenFrame, - private val parentFrame: Frame<*>?, -) : BackStackScreenScope, CoroutineScope by coroutineScope { - - @Suppress("UNCHECKED_CAST") - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflow( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = thisFrame, - ) - - @Suppress("UNCHECKED_CAST") - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink as Sink>, - parentFrame = thisFrame, - ) - - override fun continueWith(value: R) { - thisFrame.continueWith(value) - } - - override fun goBack() { - // If parent is null, goBack will not be exposed and will never be called. - val parent = checkNotNull(parentFrame) { "goBack called on root scope" } - actionSink.send(action("popTo") { - state = state.popToFrame(parent) - }) - thisFrame.cancel() - } -} - -internal sealed interface Frame { - fun cancelCaller() - suspend fun awaitResult(): R - suspend fun cancelSelf(): Nothing - fun cancel() -} - -/** - * Represents a call to [BackStackScope.showWorkflow]. - */ -internal class WorkflowFrame private constructor( - private val workflow: Workflow, - private val props: ChildPropsT, - private val callerJob: Job, - private val frameScope: CoroutineScope, - private val onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - private val actionSink: Sink>, - private val parent: Frame<*>?, - private val result: CompletableDeferred, -) : Frame { - - constructor( - workflow: Workflow, - initialProps: ChildPropsT, - callerJob: Job, - frameScope: CoroutineScope, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - actionSink: Sink>, - parent: Frame<*>?, - ) : this( - workflow = workflow, - props = initialProps, - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parent, - result = CompletableDeferred(parent = frameScope.coroutineContext.job) - ) - - fun copy( - props: ChildPropsT = this.props, - ): WorkflowFrame = WorkflowFrame( - workflow = workflow, - props = props, - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parent, - result = result - ) - - override suspend fun awaitResult(): R = result.await() - - override fun cancelCaller() { - callerJob.cancel() - } - - suspend fun finishWith(value: R): Nothing { - result.complete(value) - cancelSelf() - } - - override suspend fun cancelSelf(): Nothing { - cancel() - val currentContext = currentCoroutineContext() - currentContext.cancel() - currentContext.ensureActive() - error("Nonsense") - } - - override fun cancel() { - frameScope.cancel() - } - - fun renderWorkflow( - context: StatefulWorkflow.RenderContext - ): Screen = context.renderChild( - child = workflow, - props = props, - handler = ::onOutput - ) - - private fun onOutput(output: ChildOutputT): WorkflowAction { - var canAcceptAction = true - var action: WorkflowAction? = null - val sink = object : Sink> { - override fun send(value: WorkflowAction) { - val sendToSink = synchronized(result) { - if (canAcceptAction) { - action = value - canAcceptAction = false - false - } else { - true - } - } - if (sendToSink) { - actionSink.send(value) - } - } - } - - // Run synchronously until first suspension point since in many cases it will immediately - // either call showWorkflow, finishWith, or goBack, and so then we can just return that action - // immediately instead of needing a whole separate render pass. - frameScope.launch(start = CoroutineStart.UNDISPATCHED) { - val showScope = BackStackWorkflowScopeImpl( - actionSink = sink, - coroutineScope = this, - thisFrame = this@WorkflowFrame, - parentFrame = parent - ) - finishWith(onOutput(showScope, output)) - } - - // Once the coroutine has suspended, all sends must go to the real sink. - return synchronized(result) { - canAcceptAction = false - action ?: WorkflowAction.noAction() - } - } -} - -/** - * Represents a call to [BackStackScope.showScreen]. - */ -internal class ScreenFrame( - private val callerJob: Job, - private val frameScope: CoroutineScope, - private val actionSink: Sink>, - private val parent: Frame<*>?, -) : Frame { - private val result = CompletableDeferred() - - lateinit var screen: Screen - private set - - fun initScreen(screenFactory: BackStackScreenScope.() -> Screen) { - val factoryScope = BackStackScreenScopeImpl( - actionSink = actionSink, - coroutineScope = frameScope, - thisFrame = this, - parentFrame = parent - ) - screen = screenFactory(factoryScope) - } - - override suspend fun awaitResult(): R = result.await() - - override fun cancelCaller() { - callerJob.cancel() - } - - fun continueWith(value: R) { - result.complete(value) - cancel() - } - - override suspend fun cancelSelf(): Nothing { - cancel() - val currentContext = currentCoroutineContext() - currentContext.cancel() - currentContext.ensureActive() - error("Nonsense") - } - - override fun cancel() { - frameScope.cancel() - } -} - -// TODO concurrent calls to this function on the same scope should cancel/remove prior calls. -private suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - actionSink: Sink>, - parentFrame: Frame<*>?, -): R { - val callerContext = currentCoroutineContext() - val callerJob = callerContext.job - val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) - lateinit var frame: WorkflowFrame - - val initialProps = CompletableDeferred() - val readyForPropUpdates = Job() - frameScope.launch { - props.collect { newProps -> - if (initialProps.isActive) { - initialProps.complete(newProps) - } else { - // Ensure the frame has actually been added to the stack. - readyForPropUpdates.join() - actionSink.send(action("setProps") { - state = state.setFrameProps(frame, newProps) - }) - } - } - } - frame = WorkflowFrame( - workflow = workflow, - initialProps = initialProps.await(), - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parentFrame, - ) - - // Tell the workflow runtime to start rendering the new workflow. - actionSink.send(action("showWorkflow") { - state = state.appendFrame(frame) - }) - // Allow the props collector to send more prop update actions. Even though the initial action - // hasn't run yet, any future actions will be enqueued after it, so it's safe. - readyForPropUpdates.complete() - - return try { - frame.awaitResult() - } finally { - frameScope.cancel() - actionSink.send(action("unshowWorkflow") { - state = state.removeFrame(frame) - }) - } -} - -private suspend fun showScreenImpl( - screenFactory: BackStackScreenScope.() -> Screen, - actionSink: Sink>, - parentFrame: Frame<*>?, -): R { - val callerContext = currentCoroutineContext() - val callerJob = callerContext.job - val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) - - val frame = ScreenFrame( - callerJob = callerJob, - frameScope = frameScope, - actionSink = actionSink, - parent = parentFrame, - ) - frame.initScreen(screenFactory) - - // Tell the workflow runtime to start rendering the new workflow. - actionSink.send(action("showScreen") { - state = state.appendFrame(frame) - }) - - return try { - frame.awaitResult() - } finally { - frameScope.cancel() - actionSink.send(action("unshowScreen") { - state = state.removeFrame(frame) - }) - } -} - -internal class BackStackState( - private val stack: List>, - private val props: MutableStateFlow, -) { - - fun copy(stack: List> = this.stack) = BackStackState( - stack = stack, - props = props - ) - - fun setProps(props: Any?) { - this.props.value = props - } - - fun appendFrame(frame: Frame<*>) = copy(stack = stack + frame) - fun removeFrame(frame: Frame<*>) = copy(stack = stack - frame) - - fun popToFrame(frame: Frame<*>): BackStackState { - val index = stack.indexOf(frame) - check(index != -1) { "Frame was not in the stack!" } - - // Cancel all the frames we're about to drop, starting from the top. - for (i in stack.lastIndex downTo index + 1) { - // Don't just cancel the frame job, since that would only cancel output handlers the frame - // is running. We want to cancel the whole parent's output handler that called showWorkflow, - // in case the showWorkflow is in a try/catch that tries to make other suspending calls. - stack[i].cancelCaller() - } - - val newStack = stack.take(index + 1) - return copy(stack = newStack) - } - - fun setFrameProps( - frame: WorkflowFrame<*, *, ChildPropsT, *, *>, - newProps: ChildPropsT - ): BackStackState { - val stack = stack.toMutableList() - val myIndex = stack.indexOf(frame) - if (myIndex == -1) { - // Frame has been removed from the stack, so just no-op. - return this - } - stack[myIndex] = frame.copy(props = newProps) - return copy(stack = stack) - } - - inline fun mapFrames(block: (Frame<*>) -> R): List = stack.map(block) -} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt new file mode 100644 index 0000000000..617efccad1 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt @@ -0,0 +1,47 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +internal class BackStackWorkflowScopeImpl( + private val actionSink: Sink>, + coroutineScope: CoroutineScope, + private val thisFrame: WorkflowFrame<*, *, *, *, R>, + private val parentFrame: BackStackFrame<*>?, +) : BackStackWorkflowScope, CoroutineScope by coroutineScope { + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R + ): R = showWorkflowImpl( + workflow = workflow, + props = props, + onOutput = onOutput, + actionSink = actionSink, + parentFrame = thisFrame, + ) + + @Suppress("UNCHECKED_CAST") + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showScreenImpl( + screenFactory = screenFactory, + actionSink = actionSink as Sink>, + parentFrame = thisFrame, + ) + + override suspend fun cancelWorkflow(): Nothing { + // If parent is null, goBack will not be exposed and will never be called. + val parent = checkNotNull(parentFrame) { "goBack called on root scope" } + actionSink.send(action("popTo") { + state = state.popToFrame(parent) + }) + thisFrame.cancelSelf() + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index f7ebf7cdae..f3e1b7d3ae 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds enum class MyOutputs { @@ -21,6 +22,7 @@ data class RetryScreen( data object LoadingScreen : Screen +@Suppress("NAME_SHADOWING") class MyWorkflow( private val child1: Workflow, private val child2: Workflow, @@ -41,7 +43,7 @@ class MyWorkflow( val childResult = showWorkflow(child2) { output -> // Removes child2 from the stack, cancels the output handler from step 1, and just // leaves child1 rendering. - if (output == "back") goBack() + if (output == "back") cancelWorkflow() output } @@ -62,7 +64,8 @@ class MyWorkflow( } } - override fun createIdleScreen(): Screen = LoadingScreen + override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory = + BackStackFactory.showLoadingScreen { LoadingScreen } private suspend fun BackStackParentScope.networkCallWithRetry( request: String @@ -75,7 +78,7 @@ class MyWorkflow( message = networkResult, onRetryClicked = { continueWith(Unit) }, // Go back to showing child1. - onCancelClicked = { goBack() } + onCancelClicked = { cancelScreen() } ) } networkResult = networkCall(request) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt new file mode 100644 index 0000000000..528fcf6790 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt @@ -0,0 +1,108 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.Sink +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +// TODO Both these functions should cancel any previous calls to either function from the same frame +// (but not necessarily cancel the handler scope) before doing their own thing. + +// TODO Both these functions should withContext to the special dispatcher, to ensure its onIdle +// callback runs after their state mutations are enqueued. + +internal suspend fun showWorkflowImpl( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, + actionSink: Sink>, + parentFrame: BackStackFrame<*>?, +): R { + val callerContext = currentCoroutineContext() + val callerJob = callerContext.job + val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) + lateinit var frame: WorkflowFrame + + val initialProps = CompletableDeferred() + val readyForPropUpdates = Job() + frameScope.launch { + props.collect { newProps -> + if (initialProps.isActive) { + initialProps.complete(newProps) + } else { + // Ensure the frame has actually been added to the stack. + readyForPropUpdates.join() + actionSink.send(action("setProps") { + state = state.setFrameProps(frame, newProps) + }) + } + } + } + frame = WorkflowFrame( + workflow = workflow, + initialProps = initialProps.await(), + callerJob = callerJob, + frameScope = frameScope, + onOutput = onOutput, + actionSink = actionSink, + parent = parentFrame, + ) + + // Tell the workflow runtime to start rendering the new workflow. + actionSink.send(action("showWorkflow") { + state = state.appendFrame(frame) + }) + // Allow the props collector to send more prop update actions. Even though the initial action + // hasn't run yet, any future actions will be enqueued after it, so it's safe. + readyForPropUpdates.complete() + + return try { + frame.awaitResult() + } finally { + frameScope.cancel() + actionSink.send(action("unshowWorkflow") { + state = state.removeFrame(frame) + }) + } +} + +internal suspend fun showScreenImpl( + screenFactory: BackStackScreenScope.() -> Screen, + actionSink: Sink>, + parentFrame: BackStackFrame<*>?, +): R { + val callerContext = currentCoroutineContext() + val callerJob = callerContext.job + val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) + + val frame = ScreenFrame( + callerJob = callerJob, + frameScope = frameScope, + actionSink = actionSink, + parent = parentFrame, + ) + frame.initScreen(screenFactory) + + // Tell the workflow runtime to start rendering the new workflow. + actionSink.send(action("showScreen") { + state = state.appendFrame(frame) + }) + + return try { + frame.awaitResult() + } finally { + frameScope.cancel() + actionSink.send(action("unshowScreen") { + state = state.removeFrame(frame) + }) + } +} From 7bece0bb05fc808dab2728de20edc657e8bab9e5 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Mon, 1 Sep 2025 13:19:14 -0700 Subject: [PATCH 10/11] rewrote --- .../com/squareup/sample/thingy/ActionQueue.kt | 80 ++++++ .../sample/thingy/BackStackDispatcher.kt | 109 +++++++++ .../squareup/sample/thingy/BackStackFrame.kt | 189 +-------------- .../squareup/sample/thingy/BackStackNode.kt | 228 ++++++++++++++++++ .../sample/thingy/BackStackScopeImpl.kt | 35 --- .../sample/thingy/BackStackScreenScopeImpl.kt | 51 ---- .../squareup/sample/thingy/BackStackState.kt | 78 +++--- .../sample/thingy/BackStackWorkflow.kt | 21 +- .../sample/thingy/BackStackWorkflowImpl.kt | 93 +++---- .../thingy/BackStackWorkflowScopeImpl.kt | 47 ---- .../com/squareup/sample/thingy/MyWorkflow.kt | 4 +- .../com/squareup/sample/thingy/ShowHelpers.kt | 108 --------- 12 files changed, 534 insertions(+), 509 deletions(-) create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt create mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt delete mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt delete mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt delete mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt delete mode 100644 samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt new file mode 100644 index 0000000000..96c677e2c6 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt @@ -0,0 +1,80 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.NullableInitBox +import com.squareup.workflow1.Updater +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action + +internal typealias StateTransformation = (MutableList) -> Unit + +internal class ActionQueue { + + private val lock = Any() + + private val stateTransformations = mutableListOf() + private val outputEmissions = mutableListOf() + + fun enqueueStateTransformation(transformation: StateTransformation) { + synchronized(lock) { + stateTransformations += transformation + } + } + + fun enqueueOutputEmission(value: Any?) { + synchronized(lock) { + outputEmissions += value + } + } + + /** + * @param onNextEmitOutputAction Called when the returned action is applied if there are more + * outputs to emit. This callback should send another action into the sink to consume those + * outputs. + */ + fun consumeToAction(onNextEmitOutputAction: () -> Unit): WorkflowAction<*, *, *> = + action(name = { "ActionQueue.consumeToAction()" }) { + consume(onNextEmitOutputAction) + } + + fun consumeActionsToStack(stack: MutableList) { + val transformations = synchronized(lock) { + stateTransformations.toList().also { + stateTransformations.clear() + } + } + transformations.forEach { + it(stack) + } + } + + private fun Updater.consume( + onNextEmitOutputAction: () -> Unit + ) { + var transformations: List + var output = NullableInitBox() + var hasMoreOutputs = false + + // The workflow runtime guarantees serialization of WorkflowActions, so we only need to guard + // the actual reading of the lists in this class. + synchronized(lock) { + transformations = stateTransformations.toList() + stateTransformations.clear() + + if (outputEmissions.isNotEmpty()) { + // Can't use removeFirst on JVM, it resolves to too-new JVM method. + output = NullableInitBox(outputEmissions.removeAt(0)) + hasMoreOutputs = outputEmissions.isNotEmpty() + } + } + + if (output.isInitialized) { + setOutput(output) + } + + state = state.transformStack(transformations) + + if (hasMoreOutputs) { + onNextEmitOutputAction() + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt new file mode 100644 index 0000000000..53522df5f6 --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt @@ -0,0 +1,109 @@ +package com.squareup.sample.thingy + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlin.coroutines.CoroutineContext + +// TODO this is rough sketch, there are races +internal class BackStackDispatcher : CoroutineDispatcher() { + + private val lock = Any() + private val tasks = mutableListOf() + private var capturingTasks = false + private var delegate: CoroutineDispatcher? = null + private var onIdle: (() -> Unit)? = null + + /** + * Runs [block] then immediately runs all dispatched tasks before returning. + */ + fun runThenDispatchImmediately(block: () -> Unit) { + synchronized(lock) { + check(!capturingTasks) { "Cannot capture again" } + capturingTasks = true + } + try { + block() + } finally { + // Drain tasks before clearing capturing tasks so any tasks that dispatch are also captured. + drainTasks() + synchronized(lock) { + capturingTasks = false + } + // Run one last time in case tasks were enqueued while clearing the capture flag. + drainTasks() + } + } + + /** + * Suspends this coroutine indefinitely and dispatches any tasks to the current dispatcher. + * [onIdle] is called after processing tasks when there are no more tasks to process. + */ + @OptIn(ExperimentalStdlibApi::class) + suspend fun runDispatch(onIdle: () -> Unit): Nothing { + val delegate = currentCoroutineContext()[CoroutineDispatcher] ?: Dispatchers.Default + synchronized(lock) { + check(this.delegate == null) { "Expected runDispatch to only be called once concurrently" } + this.delegate = delegate + this.onIdle = onIdle + } + + try { + awaitCancellation() + } finally { + synchronized(lock) { + this.delegate = null + this.onIdle = null + } + } + } + + override fun dispatch( + context: CoroutineContext, + block: Runnable + ) { + var isCapturing: Boolean + var isFirstTask: Boolean + var delegate: CoroutineDispatcher? + var onIdle: (() -> Unit)? + + synchronized(lock) { + tasks += block + isFirstTask = tasks.size == 1 + isCapturing = this.capturingTasks + delegate = this.delegate + onIdle = this.onIdle + } + + if (!isCapturing && delegate != null && onIdle != null && isFirstTask) { + delegate!!.dispatch(context) { + // Only run onIdle if work was actually done. + if (drainTasks()) { + onIdle!!() + } + } + } + } + + /** + * Returns true if any tasks were executed. + */ + private fun drainTasks(): Boolean { + var didAnything = false + var task = getNextTask() + while (task != null) { + didAnything = true + task.run() + task = getNextTask() + } + return didAnything + } + + private fun getNextTask(): Runnable? { + synchronized(lock) { + return tasks.removeFirstOrNull() + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt index 52e31643f5..1865cfd25d 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt @@ -1,191 +1,18 @@ package com.squareup.sample.thingy -import com.squareup.workflow1.Sink import com.squareup.workflow1.StatefulWorkflow.RenderContext -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.WorkflowAction.Companion import com.squareup.workflow1.ui.Screen -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.job -import kotlinx.coroutines.launch -internal sealed interface BackStackFrame { - fun cancelCaller() - suspend fun awaitResult(): R - suspend fun cancelSelf(): Nothing - fun cancel() -} - -/** - * Represents a call to [BackStackScope.showWorkflow]. - */ -internal class WorkflowFrame private constructor( - private val workflow: Workflow, - private val props: ChildPropsT, - private val callerJob: Job, - private val frameScope: CoroutineScope, - private val onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - private val actionSink: Sink>, - private val parent: BackStackFrame<*>?, - private val result: CompletableDeferred, -) : BackStackFrame { - - constructor( - workflow: Workflow, - initialProps: ChildPropsT, - callerJob: Job, - frameScope: CoroutineScope, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - actionSink: Sink>, - parent: BackStackFrame<*>?, - ) : this( - workflow = workflow, - props = initialProps, - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parent, - result = CompletableDeferred(parent = frameScope.coroutineContext.job) - ) - - fun copy( - props: ChildPropsT = this.props, - ): WorkflowFrame = WorkflowFrame( - workflow = workflow, - props = props, - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parent, - result = result - ) - - override suspend fun awaitResult(): R = result.await() - - override fun cancelCaller() { - callerJob.cancel() - } - - private suspend fun finishWith(value: R): Nothing { - result.complete(value) - cancelSelf() - } - - override suspend fun cancelSelf(): Nothing { - cancel() - val currentContext = currentCoroutineContext() - currentContext.cancel() - currentContext.ensureActive() - error("Nonsense") - } - - override fun cancel() { - frameScope.cancel() - } - - fun renderWorkflow( - context: RenderContext - ): Screen = context.renderChild( - child = workflow, - props = props, - handler = ::onOutput - ) +internal interface BackStackFrame { + val node: BackStackNode - private fun onOutput(output: ChildOutputT): WorkflowAction { - var canAcceptAction = true - var action: WorkflowAction? = null - val sink = object : Sink> { - override fun send(value: WorkflowAction) { - val sendToSink = synchronized(result) { - if (canAcceptAction) { - action = value - canAcceptAction = false - false - } else { - true - } - } - if (sendToSink) { - actionSink.send(value) - } - } - } + val isIdle: Boolean + get() = false - // Run synchronously until first suspension point since in many cases it will immediately - // either call showWorkflow, finishWith, or goBack, and so then we can just return that action - // immediately instead of needing a whole separate render pass. - frameScope.launch(start = UNDISPATCHED) { - val showScope = BackStackWorkflowScopeImpl( - actionSink = sink, - coroutineScope = this, - thisFrame = this@WorkflowFrame, - parentFrame = parent - ) - finishWith(onOutput(showScope, output)) - } - // TODO collect WorkflowAction - - // Once the coroutine has suspended, all sends must go to the real sink. - return synchronized(result) { - canAcceptAction = false - action ?: WorkflowAction.noAction() - } - } -} - -/** - * Represents a call to [BackStackScope.showScreen]. - */ -internal class ScreenFrame( - private val callerJob: Job, - private val frameScope: CoroutineScope, - private val actionSink: Sink>, - private val parent: BackStackFrame<*>?, -) : BackStackFrame { - private val result = CompletableDeferred() - - lateinit var screen: Screen - private set - - fun initScreen(screenFactory: BackStackScreenScope.() -> Screen) { - val factoryScope = BackStackScreenScopeImpl( - actionSink = actionSink, - coroutineScope = frameScope, - thisFrame = this, - parentFrame = parent - ) - screen = screenFactory(factoryScope) - } - - override suspend fun awaitResult(): R = result.await() - - override fun cancelCaller() { - callerJob.cancel() - } - - fun continueWith(value: R) { - result.complete(value) - cancel() + fun withIdle(): BackStackFrame = object : BackStackFrame by this { + override val isIdle: Boolean + get() = true } - override suspend fun cancelSelf(): Nothing { - cancel() - val currentContext = currentCoroutineContext() - currentContext.cancel() - currentContext.ensureActive() - error("Nonsense") - } - - override fun cancel() { - frameScope.cancel() - } + fun render(context: RenderContext): Screen } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt new file mode 100644 index 0000000000..7bf6dbed8f --- /dev/null +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt @@ -0,0 +1,228 @@ +package com.squareup.sample.thingy + +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.ui.Screen +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@OptIn(ExperimentalAtomicApi::class) +internal class BackStackNode( + private val actionQueue: ActionQueue, + private val key: String, + parentJob: Job, + private val dispatcher: BackStackDispatcher, + private val onCancel: () -> Unit, + private val onEmitOutputAction: () -> Unit +) : BackStackScope, BackStackScreenScope { + + private val result = CompletableDeferred(parent = parentJob) + private val workerScope = CoroutineScope(result + dispatcher) + + private val activeChildLock = Any() + + /** All access must be guarded by [activeChildLock]. */ + private var activeChild: BackStackNode? = null + + private var childWorkflowKeyCounter = AtomicInt(0) + + private fun createNewChildKey(): String { + val id = childWorkflowKeyCounter.fetchAndAdd(1) + return "$key.$id" + } + + /** + * Tracks how many calls to [launch] are currently running. All access must be guarded by + * [activeChildLock]. + * + * The node is idle when this value > 0 and [activeChild] is null. + */ + private var workers = 0 + + private val isIdle: Boolean + get() = synchronized(activeChildLock) { + workers > 0 && activeChild == null + } + + override suspend fun showWorkflow( + workflow: Workflow, + props: Flow, + onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R + ): R = showNode { workflowNode -> + props.map { props -> + object : BackStackFrame { + override val node: BackStackNode + get() = workflowNode + + override fun render(context: RenderContext): Screen = + context.renderChild( + child = workflow, + key = workflowNode.key, + props = props, + handler = { output -> + dispatcher.runThenDispatchImmediately { + workflowNode.launch { + val scope = object : BackStackWorkflowScope, + BackStackScope by workflowNode, + CoroutineScope by this { + + override suspend fun cancelWorkflow(): Nothing { + workflowNode.onCancel() + currentCoroutineContext().ensureActive() + error( + "cancelWorkflow() called from a coroutine that was not a child of the " + + "BackStackWorkflowScope" + ) + } + } + onOutput(scope, output) + } + } + @Suppress("UNCHECKED_CAST") + actionQueue.consumeToAction(onEmitOutputAction) as + WorkflowAction + } + ) + } + } + } + + override suspend fun showScreen( + screenFactory: BackStackScreenScope.() -> Screen + ): R = showNode { screenNode -> + flow { + @Suppress("UNCHECKED_CAST") + val screen = screenFactory(screenNode as BackStackScreenScope) + emit(object : BackStackFrame { + override val node: BackStackNode + get() = screenNode + + override fun render(context: RenderContext): Screen = screen + }) + } + } + + override fun launch(block: suspend CoroutineScope.() -> Unit) { + workerScope.launch { + synchronized(activeChildLock) { workers++ } + updateFrame() + + try { + // Need a child scope here to wait for any coroutines launched inside block to finish before + // decrementing workers. + coroutineScope { + block() + } + } finally { + synchronized(activeChildLock) { workers-- } + updateFrame() + } + } + } + + private suspend fun showNode( + block: CoroutineScope.(BackStackNode) -> Flow + ): R = withContext(dispatcher) { + val childJob = coroutineContext.job + val childNode = BackStackNode( + actionQueue = actionQueue, + key = createNewChildKey(), + parentJob = childJob, + dispatcher = dispatcher, + onCancel = result::cancelChildren, + onEmitOutputAction = onEmitOutputAction, + ) + + withActiveChild(childNode) { + val frames = block(childNode) + showFrames(childNode, frames, frameScope = this) { + @Suppress("UNCHECKED_CAST") + childNode.result.await() as R + } + } + } + + private suspend inline fun showFrames( + childNode: BackStackNode, + frames: Flow, + frameScope: CoroutineScope, + crossinline block: suspend () -> R + ): R { + try { + frames + .onEach { newFrame -> + childNode.updateFrame { newFrame } + } + .launchIn(frameScope) + + return block() + } finally { + // Remove this node's frame. + childNode.updateFrame { null } + } + } + + override fun continueWith(value: Any?) { + if (!result.complete(value)) { + error("Tried to finish with $value but already finished") + } + } + + override fun cancelScreen() { + onCancel() + } + + private suspend inline fun withActiveChild( + child: BackStackNode, + block: () -> R + ): R { + val oldChild = synchronized(activeChildLock) { + activeChild.also { activeChild = child } + } + oldChild?.result?.cancelAndJoin() + + try { + return block() + } finally { + synchronized(activeChildLock) { + // If we're being canceled by another withActiveChild call, don't overwrite the new child. + if (activeChild === child) { + activeChild = null + } + } + } + } + + private fun updateFrame( + update: ((BackStackFrame?) -> BackStackFrame?)? = null + ) { + val isIdle = isIdle + actionQueue.enqueueStateTransformation { frames -> + val index = frames.indexOfFirst { it.node === this } + val previousFrame = if (index == -1) null else frames[index] + val newFrame = if (update != null) update(previousFrame) else previousFrame + if (newFrame == null) { + frames.removeAt(index) + } else { + frames[index] = if (isIdle) newFrame.withIdle() else newFrame + } + } + } +} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt deleted file mode 100644 index 51174ff3f2..0000000000 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScopeImpl.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.squareup.sample.thingy - -import com.squareup.workflow1.Sink -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.ui.Screen -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow - -internal class BackStackScopeImpl( - coroutineScope: CoroutineScope, -) : BackStackScope, CoroutineScope by coroutineScope { - // TODO set this - lateinit var actionSink: Sink> - - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflowImpl( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = null - ) - - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink, - parentFrame = null - ) -} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt deleted file mode 100644 index 9605a35b9f..0000000000 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackScreenScopeImpl.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.squareup.sample.thingy - -import com.squareup.workflow1.Sink -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.Screen -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow - -internal class BackStackScreenScopeImpl( - private val actionSink: Sink>, - coroutineScope: CoroutineScope, - private val thisFrame: ScreenFrame, - private val parentFrame: BackStackFrame<*>?, -) : BackStackScreenScope, CoroutineScope by coroutineScope { - - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflowImpl( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = thisFrame, - ) - - @Suppress("UNCHECKED_CAST") - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink as Sink>, - parentFrame = thisFrame, - ) - - override fun continueWith(value: R) { - thisFrame.continueWith(value) - } - - override fun cancelScreen() { - // If parent is null, goBack will not be exposed and will never be called. - val parent = checkNotNull(parentFrame) { "goBack called on root scope" } - actionSink.send(action("popTo") { - state = state.popToFrame(parent) - }) - thisFrame.cancel() - } -} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt index 4eb70bd35c..f091eabb56 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt @@ -1,5 +1,7 @@ package com.squareup.sample.thingy +import com.squareup.workflow1.StatefulWorkflow.RenderContext +import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.BackStackScreen import kotlinx.coroutines.flow.MutableStateFlow @@ -87,59 +89,57 @@ TODO: Design for coalescing state updates/output emissions and dispatching */ // Impl note: Does some casting to avoid dealing with generics everywhere, since this is internal- -// only. Can we use this trick in more places? +// only. internal class BackStackState( - private val stack: List>, + private val stack: List, private val props: MutableStateFlow, private val backStackFactory: BackStackFactory, + private val actionQueue: ActionQueue, + private val dispatcher: BackStackDispatcher, ) { - fun copy(stack: List> = this.stack) = BackStackState( - stack = stack, - props = props, - backStackFactory = backStackFactory, - ) + fun setProps(props: Any?): BackStackState { + dispatcher.runThenDispatchImmediately { + this.props.value = props + } - fun setProps(props: Any?) { - this.props.value = props + val mutableStack = stack.toMutableList() + actionQueue.consumeActionsToStack(mutableStack) + return copy(stack = mutableStack) } - fun appendFrame(frame: BackStackFrame<*>) = copy(stack = stack + frame) - fun removeFrame(frame: BackStackFrame<*>) = copy(stack = stack - frame) - - fun popToFrame(frame: BackStackFrame<*>): BackStackState { - val index = stack.indexOf(frame) - check(index != -1) { "Frame was not in the stack!" } - - // Cancel all the frames we're about to drop, starting from the top. - for (i in stack.lastIndex downTo index + 1) { - // Don't just cancel the frame job, since that would only cancel output handlers the frame - // is running. We want to cancel the whole parent's output handler that called showWorkflow, - // in case the showWorkflow is in a try/catch that tries to make other suspending calls. - stack[i].cancelCaller() + fun renderOn(context: RenderContext): BackStackScreen { + context.runningSideEffect("TODO") { + dispatcher.runDispatch(onIdle = { + sendActionToSink(context) + }) } - val newStack = stack.take(index + 1) - return copy(stack = newStack) + val renderings = stack.map { frame -> + frame.render(context) + } + return backStackFactory.createBackStack(renderings, isTopIdle = false) } - fun setFrameProps( - frame: WorkflowFrame<*, *, ChildPropsT, *, *>, - newProps: ChildPropsT - ): BackStackState { - val stack = stack.toMutableList() - val myIndex = stack.indexOf(frame) - if (myIndex == -1) { - // Frame has been removed from the stack, so just no-op. - return this + fun transformStack(transformations: List): BackStackState { + val mutableStack = stack.toMutableList() + transformations.forEach { + it(mutableStack) } - stack[myIndex] = frame.copy(props = newProps) - return copy(stack = stack) + return copy(stack = mutableStack) } - inline fun mapFrames(block: (BackStackFrame<*>) -> R): List = stack.map(block) + private fun sendActionToSink(context: RenderContext) { + @Suppress("UNCHECKED_CAST") + context.actionSink.send(actionQueue.consumeToAction { sendActionToSink(context) } as + WorkflowAction) + } - fun createBackStack(screens: List): BackStackScreen = - // TODO pass isTopIdle - backStackFactory.createBackStack(screens, isTopIdle = false) + private fun copy(stack: List = this.stack) = BackStackState( + stack = stack, + props = props, + backStackFactory = backStackFactory, + actionQueue = actionQueue, + dispatcher = dispatcher, + ) } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt index 0ac9312352..b6d8c13b97 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt @@ -87,7 +87,7 @@ public abstract class BackStackWorkflow : * * # Emitting output * - * The second parameter to the [runBackStack] function is an [emitOutput] function that will send + * The second parameter to the [launchRunBackStack] function is an [emitOutput] function that will send * whatever you pass to it to this workflow's parent as an output. * ``` * backStackWorkflow { _, emitOutput -> @@ -301,14 +301,14 @@ public sealed interface BackStackParentScope { } @BackStackWorkflowDsl -public sealed interface BackStackScope : BackStackParentScope, CoroutineScope +public interface BackStackScope : BackStackParentScope /** * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of * [BackStackScope] with the additional ability to [go back][cancelWorkflow] to its outer workflow. */ @BackStackWorkflowDsl -public sealed interface BackStackWorkflowScope : BackStackScope { +public interface BackStackWorkflowScope : BackStackScope, CoroutineScope { /** * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflowImpl] @@ -324,15 +324,23 @@ public sealed interface BackStackWorkflowScope : BackStackScope { * to return from [showScreen] by calling [continueWith]. */ @BackStackWorkflowDsl -public sealed interface BackStackScreenScope : BackStackScope { +public interface BackStackScreenScope : BackStackScope { /** * Causes [showScreen] to return with [value]. */ fun continueWith(value: R) fun cancelScreen() + + fun launch(block: suspend CoroutineScope.() -> Unit) } +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + props: ChildPropsT, + noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R +): R = showWorkflow(workflow, props = flowOf(props), onOutput = onOutput) + public suspend inline fun BackStackParentScope.showWorkflow( workflow: Workflow, noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R @@ -343,6 +351,11 @@ public suspend inline fun BackStackParentScope.showWorkflow( props: Flow, ): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") } +public suspend inline fun BackStackParentScope.showWorkflow( + workflow: Workflow, + props: ChildPropsT, +): Nothing = showWorkflow(workflow, props = flowOf(props)) { error("Cannot call") } + public suspend inline fun BackStackParentScope.showWorkflow( workflow: Workflow, ): Nothing = showWorkflow(workflow, props = flowOf(Unit)) { error("Cannot call") } diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt index d197ddee84..41d0e4c012 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt @@ -1,16 +1,16 @@ package com.squareup.sample.thingy import com.squareup.workflow1.SessionWorkflow -import com.squareup.workflow1.Sink import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalApi -import com.squareup.workflow1.action +import com.squareup.workflow1.identifier import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.navigation.BackStackScreen import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job import kotlinx.coroutines.launch @OptIn(WorkflowExperimentalApi::class) @@ -30,61 +30,72 @@ internal class BackStackWorkflowImpl( ): BackStackState { val propsFlow = MutableStateFlow(props) val backStackFactory = workflow.getBackStackFactory(workflowScope.coroutineContext) + val actionQueue = ActionQueue() + val dispatcher = BackStackDispatcher() + val rootJob = Job(parent = workflowScope.coroutineContext.job) + lateinit var rootNode: BackStackNode - @Suppress("UNCHECKED_CAST") - val initialState = BackStackState( - stack = emptyList(), - props = propsFlow as MutableStateFlow, - backStackFactory = backStackFactory, - ) + fun launchRootNode() { + rootNode.launch { + with(workflow) { + rootNode.runBackStack( + props = propsFlow, + emitOutput = { output -> + // Launch with dispatcher to trigger onIdle and actually enqueue an action. + workflowScope.launch(dispatcher) { + actionQueue.enqueueOutputEmission(output) + } + } + ) + } + } + } - // TODO move this into the launch call so the scope is correct (use this instead of - // workflowScope). - val scope = BackStackScopeImpl( - coroutineScope = workflowScope, - ) - workflowScope.launch(start = CoroutineStart.UNDISPATCHED) { - with(workflow) { - scope.runBackStack(propsFlow, emitOutput = { output -> - @Suppress("UNCHECKED_CAST") - (scope.actionSink as Sink>) - .send(action("emitOutput") { setOutput(output) }) - }) + rootNode = BackStackNode( + actionQueue = actionQueue, + key = workflow.identifier.toString(), + parentJob = workflowScope.coroutineContext.job, + dispatcher = dispatcher, + onCancel = { + rootJob.cancelChildren() + launchRootNode() + }, + onEmitOutputAction = { + TODO("how to trigger more actions?") } + ) + + dispatcher.runThenDispatchImmediately { + launchRootNode() } - // TODO gather initial state from the coroutine + val initialStack = buildList { + actionQueue.consumeActionsToStack(this) + } - return initialState + @Suppress("UNCHECKED_CAST") + return BackStackState( + stack = initialStack, + props = propsFlow as MutableStateFlow, + backStackFactory = backStackFactory, + actionQueue = actionQueue, + dispatcher = dispatcher, + ) } override fun onPropsChanged( old: PropsT, new: PropsT, state: BackStackState - ): BackStackState = state.apply { - setProps(new) - // TODO gather updated state from coroutine - } + ): BackStackState = state.setProps(new) override fun render( renderProps: PropsT, renderState: BackStackState, context: RenderContext ): BackStackScreen { - val renderings = renderState.mapFrames { frame -> - when (frame) { - is WorkflowFrame<*, *, *, *, *> -> { - @Suppress("UNCHECKED_CAST") - (frame as WorkflowFrame).renderWorkflow(context) - } - - is ScreenFrame<*, *> -> { - frame.screen - } - } - } - return renderState.createBackStack(renderings) + @Suppress("UNCHECKED_CAST") + return renderState.renderOn(context as RenderContext) } override fun snapshotState(state: BackStackState): Snapshot? = null diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt deleted file mode 100644 index 617efccad1..0000000000 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowScopeImpl.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.squareup.sample.thingy - -import com.squareup.workflow1.Sink -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.Screen -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow - -internal class BackStackWorkflowScopeImpl( - private val actionSink: Sink>, - coroutineScope: CoroutineScope, - private val thisFrame: WorkflowFrame<*, *, *, *, R>, - private val parentFrame: BackStackFrame<*>?, -) : BackStackWorkflowScope, CoroutineScope by coroutineScope { - - override suspend fun showWorkflow( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R - ): R = showWorkflowImpl( - workflow = workflow, - props = props, - onOutput = onOutput, - actionSink = actionSink, - parentFrame = thisFrame, - ) - - @Suppress("UNCHECKED_CAST") - override suspend fun showScreen( - screenFactory: BackStackScreenScope.() -> Screen - ): R = showScreenImpl( - screenFactory = screenFactory, - actionSink = actionSink as Sink>, - parentFrame = thisFrame, - ) - - override suspend fun cancelWorkflow(): Nothing { - // If parent is null, goBack will not be exposed and will never be called. - val parent = checkNotNull(parentFrame) { "goBack called on root scope" } - actionSink.send(action("popTo") { - state = state.popToFrame(parent) - }) - thisFrame.cancelSelf() - } -} diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index f3e1b7d3ae..42759da9a6 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -4,7 +4,6 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.ui.Screen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds @@ -56,7 +55,7 @@ class MyWorkflow( delay(3.seconds) emitOutput(MyOutputs.Done) } - showWorkflow(child3, flowOf(networkResult)) + showWorkflow(child3, networkResult) } else -> error("Unexpected output: $output") @@ -70,7 +69,6 @@ class MyWorkflow( private suspend fun BackStackParentScope.networkCallWithRetry( request: String ): String { - // TODO: Show a loading screen automatically. var networkResult = networkCall(request) while (networkResult == "failure") { showScreen { diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt deleted file mode 100644 index 528fcf6790..0000000000 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ShowHelpers.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.squareup.sample.thingy - -import com.squareup.workflow1.Sink -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.Screen -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.job -import kotlinx.coroutines.launch - -// TODO Both these functions should cancel any previous calls to either function from the same frame -// (but not necessarily cancel the handler scope) before doing their own thing. - -// TODO Both these functions should withContext to the special dispatcher, to ensure its onIdle -// callback runs after their state mutations are enqueued. - -internal suspend fun showWorkflowImpl( - workflow: Workflow, - props: Flow, - onOutput: suspend BackStackWorkflowScope.(ChildOutputT) -> R, - actionSink: Sink>, - parentFrame: BackStackFrame<*>?, -): R { - val callerContext = currentCoroutineContext() - val callerJob = callerContext.job - val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) - lateinit var frame: WorkflowFrame - - val initialProps = CompletableDeferred() - val readyForPropUpdates = Job() - frameScope.launch { - props.collect { newProps -> - if (initialProps.isActive) { - initialProps.complete(newProps) - } else { - // Ensure the frame has actually been added to the stack. - readyForPropUpdates.join() - actionSink.send(action("setProps") { - state = state.setFrameProps(frame, newProps) - }) - } - } - } - frame = WorkflowFrame( - workflow = workflow, - initialProps = initialProps.await(), - callerJob = callerJob, - frameScope = frameScope, - onOutput = onOutput, - actionSink = actionSink, - parent = parentFrame, - ) - - // Tell the workflow runtime to start rendering the new workflow. - actionSink.send(action("showWorkflow") { - state = state.appendFrame(frame) - }) - // Allow the props collector to send more prop update actions. Even though the initial action - // hasn't run yet, any future actions will be enqueued after it, so it's safe. - readyForPropUpdates.complete() - - return try { - frame.awaitResult() - } finally { - frameScope.cancel() - actionSink.send(action("unshowWorkflow") { - state = state.removeFrame(frame) - }) - } -} - -internal suspend fun showScreenImpl( - screenFactory: BackStackScreenScope.() -> Screen, - actionSink: Sink>, - parentFrame: BackStackFrame<*>?, -): R { - val callerContext = currentCoroutineContext() - val callerJob = callerContext.job - val frameScope = CoroutineScope(callerContext + Job(parent = callerJob)) - - val frame = ScreenFrame( - callerJob = callerJob, - frameScope = frameScope, - actionSink = actionSink, - parent = parentFrame, - ) - frame.initScreen(screenFactory) - - // Tell the workflow runtime to start rendering the new workflow. - actionSink.send(action("showScreen") { - state = state.appendFrame(frame) - }) - - return try { - frame.awaitResult() - } finally { - frameScope.cancel() - actionSink.send(action("unshowScreen") { - state = state.removeFrame(frame) - }) - } -} From 0945f30e34570397821463bfe448404c2fba7d96 Mon Sep 17 00:00:00 2001 From: zach-klippenstein <101754+zach-klippenstein@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:45:11 +0000 Subject: [PATCH 11/11] Apply changes from ktLintFormat Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../squareup/sample/thingy/BackStackNode.kt | 3 ++- .../squareup/sample/thingy/BackStackState.kt | 24 ++++++++++--------- .../com/squareup/sample/thingy/MyWorkflow.kt | 4 ++-- .../workflow1/ui/navigation/Thingy.kt | 3 +-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt index 7bf6dbed8f..d93ff29f62 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt @@ -79,7 +79,8 @@ internal class BackStackNode( handler = { output -> dispatcher.runThenDispatchImmediately { workflowNode.launch { - val scope = object : BackStackWorkflowScope, + val scope = object : + BackStackWorkflowScope, BackStackScope by workflowNode, CoroutineScope by this { diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt index f091eabb56..d262020e48 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt @@ -41,18 +41,18 @@ TODO: Design for coalescing state updates/output emissions and dispatching … /** - * Suspends indefinitely, handling any dispatch calls that aren't inside a [runThenDrain] by - * dispatching to the dispatcher from the current context. After processing at least one task, - * when there are no more tasks enqueued, calls [onIdle]. - */ + * Suspends indefinitely, handling any dispatch calls that aren't inside a [runThenDrain] by + * dispatching to the dispatcher from the current context. After processing at least one task, + * when there are no more tasks enqueued, calls [onIdle]. + */ suspend fun run(onIdle: () -> Unit) /** - * Runs [block] such that any tasks that are dispatched to this dispatcher by [block] are not - * dispatched like normal, but collected into a special queue and all ran after [block] returns - * but before this function returns. I.e. any coroutine work started by [block] is guaranteed to - * be have been run and the dispatcher will be idle when this function returns. - */ + * Runs [block] such that any tasks that are dispatched to this dispatcher by [block] are not + * dispatched like normal, but collected into a special queue and all ran after [block] returns + * but before this function returns. I.e. any coroutine work started by [block] is guaranteed to + * be have been run and the dispatcher will be idle when this function returns. + */ fun runThenDrain(block: () -> Unit) } @@ -131,8 +131,10 @@ internal class BackStackState( private fun sendActionToSink(context: RenderContext) { @Suppress("UNCHECKED_CAST") - context.actionSink.send(actionQueue.consumeToAction { sendActionToSink(context) } as - WorkflowAction) + context.actionSink.send( + actionQueue.consumeToAction { sendActionToSink(context) } as + WorkflowAction + ) } private fun copy(stack: List = this.stack) = BackStackState( diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt index 42759da9a6..837ba0a6bb 100644 --- a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt +++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt @@ -40,8 +40,8 @@ class MyWorkflow( "next" -> { // Step 2 val childResult = showWorkflow(child2) { output -> - // Removes child2 from the stack, cancels the output handler from step 1, and just - // leaves child1 rendering. + // Removes child2 from the stack, cancels the output handler from step 1, and just + // leaves child1 rendering. if (output == "back") cancelWorkflow() output } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt index 524d4feebd..d715902c01 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt @@ -1,4 +1,3 @@ package com.squareup.workflow1.ui.navigation -public class Thingy : Stateful { -} +public class Thingy : Stateful