From c007b730e49d97b8afcbacc75c186906de0363c7 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 25 Feb 2025 18:04:49 -0800 Subject: [PATCH 1/5] WIP Sketching Compose-based workflows. See https://www.notion.so/block-xyz/Compose-based-Workflows-1a6be352683880b98600dde8e4413fb9?pvs=4 --- settings.gradle.kts | 1 + workflow-core/build.gradle.kts | 2 + .../squareup/workflow1/BaseRenderContext.kt | 46 +++++++++++++ .../com/squareup/workflow1/ComposeWorkflow.kt | 52 ++++++++++++++ .../com/squareup/workflow1/UnitApplier.kt | 42 ++++++++++++ .../squareup/workflow1/WorkflowComposable.kt | 23 +++++++ .../squareup/workflow1/WorkflowComposables.kt | 68 +++++++++++++++++++ 7 files changed, 234 insertions(+) create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 0cc8d2b26..394e1b285 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ pluginManagement { google() // For binary compatibility validator. maven { url = uri("https://kotlin.bintray.com/kotlinx") } + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } includeBuild("build-logic") } diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index d0cbed541..e34abadba 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 plugins { id("kotlin-multiplatform") id("published") + // id("org.jetbrains.compose") version "1.7.3" } kotlin { @@ -23,6 +24,7 @@ dependencies { commonMainApi(libs.kotlinx.coroutines.core) // For Snapshot. commonMainApi(libs.squareup.okio) + commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3") commonTestImplementation(libs.kotlinx.atomicfu) commonTestImplementation(libs.kotlinx.coroutines.test.common) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index e26614557..8ede579d1 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -9,8 +9,17 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf import com.squareup.workflow1.WorkflowAction.Companion.noAction import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlin.coroutines.EmptyCoroutineContext import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName import kotlin.reflect.KType @@ -85,6 +94,43 @@ public interface BaseRenderContext { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + /** + * Synchronously composes a [content] function and returns its rendering. Whenever [content] is + * invalidated, this workflow will be re-rendered and the [content] recomposed to return its new + * value. + * + * @see ComposeWorkflow + */ + public fun renderComposable( + key: String = "", + content: @WorkflowComposable @Composable () -> ChildRenderingT + ): ChildRenderingT { + val renderer: WorkflowComposableRenderer = TODO() + val frameClock: MonotonicFrameClock = TODO() + val coroutineContext = EmptyCoroutineContext + frameClock + val recomposer = Recomposer(coroutineContext) + val composition = Composition(UnitApplier, recomposer) + + // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to + // pump the dispatcher until the composition is finished. + CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { + try { + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + + val rendering = mutableStateOf(null) + composition.setContent { + CompositionLocalProvider(LocalWorkflowComposableRenderer provides renderer) { + rendering.value = content() + } + } + @Suppress("UNCHECKED_CAST") + return rendering.value as ChildRenderingT + } + /** * Ensures [sideEffect] is running with the given [key]. * diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt new file mode 100644 index 000000000..267d07ba9 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt @@ -0,0 +1,52 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember + +/** + * A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable. + */ +@Stable +public interface ComposeWorkflow< + in PropsT, + out OutputT, + out RenderingT + > { + + /** + * The main composable of this workflow that consumes some [props] from its parent and may emit + * an output via [emitOutput]. + * + * Equivalent to [StatefulWorkflow.render]. + */ + @WorkflowComposable + @Composable + fun Rendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT +} + +fun < + PropsT, StateT, OutputT, + ChildPropsT, ChildOutputT, ChildRenderingT + > BaseRenderContext.renderChild( + child: ComposeWorkflow, + props: ChildPropsT, + key: String = "", + handler: (ChildOutputT) -> WorkflowAction +): ChildRenderingT = renderComposable(key = key) { + // Explicitly remember the output function since we know that actionSink is stable even though + // Compose might not know that. + val emitOutput: (ChildOutputT) -> Unit = remember(actionSink) { + { output -> + val action = handler(output) + actionSink.send(action) + } + } + child.Rendering( + props = props, + emitOutput = emitOutput + ) +} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt new file mode 100644 index 000000000..309a14cd0 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt @@ -0,0 +1,42 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.Applier + +internal object UnitApplier : Applier { + override val current: Unit + get() = Unit + + override fun clear() { + } + + override fun down(node: Unit) { + } + + override fun insertBottomUp( + index: Int, + instance: Unit + ) { + } + + override fun insertTopDown( + index: Int, + instance: Unit + ) { + } + + override fun move( + from: Int, + to: Int, + count: Int + ) { + } + + override fun remove( + index: Int, + count: Int + ) { + } + + override fun up() { + } +} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt new file mode 100644 index 000000000..a8e02afb7 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt @@ -0,0 +1,23 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.ComposableTargetMarker +import kotlin.annotation.AnnotationRetention.BINARY +import kotlin.annotation.AnnotationTarget.FILE +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER +import kotlin.annotation.AnnotationTarget.TYPE +import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER + +/** + * An annotation that can be used to mark a composable function as being expected to be use in a + * composable function that is also marked or inferred to be marked as a [WorkflowComposable], i.e. + * that can be called from [BaseRenderContext.renderComposable]. + * + * Using this annotation explicitly is rarely necessary as the Compose compiler plugin will infer + * the necessary equivalent annotations automatically. See + * [androidx.compose.runtime.ComposableTarget] for details. + */ +@ComposableTargetMarker(description = "Workflow Composable") +@Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER) +@Retention(BINARY) +annotation class WorkflowComposable diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt new file mode 100644 index 000000000..281b20760 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt @@ -0,0 +1,68 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalWorkflowComposableRenderer = + staticCompositionLocalOf { error("No renderer") } + +internal interface WorkflowComposableRenderer { + @Composable + fun Child( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT +} + +/** + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or + * [BaseRenderContext.renderComposable]) and returns its rendering. + * + * @param handler An optional function that, if non-null, will be called when the child emits an + * output. If null, the child's outputs will be ignored. + */ +@WorkflowComposable +@Composable +fun Child( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? +): ChildRenderingT { + val renderer = LocalWorkflowComposableRenderer.current + return renderer.Child(workflow, props, onOutput) +} + +@WorkflowComposable +@Composable +fun Child( + workflow: ComposeWorkflow, + props: ChildPropsT, + handler: ((ChildOutputT) -> Unit)? +): ChildRenderingT { + val childRendering = remember { mutableStateOf(null) } + // Since this function returns a value, it can't restart without also restarting its parent. + // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value + // actually changed. + IsolateRecomposeScope( + child = workflow, + props = props, + handler = handler, + result = childRendering + ) + @Suppress("UNCHECKED_CAST") + return childRendering.value as ChildRenderingT +} + +@Composable +private fun IsolateRecomposeScope( + child: ComposeWorkflow, + props: PropsT, + handler: ((OutputT) -> Unit)?, + result: MutableState, +) { + result.value = child.Rendering(props, handler ?: {}) +} From 2abe3402dc899f0db33327049406532b77a0521f Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Thu, 27 Feb 2025 22:12:49 -0800 Subject: [PATCH 2/5] more sketching the impl --- .../squareup/workflow1/BaseRenderContext.kt | 38 +------ .../squareup/workflow1/WorkflowComposables.kt | 68 ------------- .../{ => compose}/ComposeWorkflow.kt | 6 +- .../{ => compose}/WorkflowComposable.kt | 2 +- .../workflow1/compose/WorkflowComposables.kt | 98 +++++++++++++++++++ .../compose/WorkflowCompositionHost.kt | 32 ++++++ workflow-runtime/build.gradle.kts | 7 ++ .../internal/ComposedWorkflowChild.kt | 52 ++++++++++ .../workflow1/internal/RealRenderContext.kt | 14 +++ .../workflow1/internal/SubtreeManager.kt | 91 ++++++++++++++++- .../workflow1/internal}/UnitApplier.kt | 2 +- 11 files changed, 303 insertions(+), 107 deletions(-) delete mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt rename workflow-core/src/commonMain/kotlin/com/squareup/workflow1/{ => compose}/ComposeWorkflow.kt (86%) rename workflow-core/src/commonMain/kotlin/com/squareup/workflow1/{ => compose}/WorkflowComposable.kt (96%) create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt rename {workflow-core/src/commonMain/kotlin/com/squareup/workflow1 => workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal}/UnitApplier.kt (92%) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index 8ede579d1..47306c1f7 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -10,16 +10,9 @@ package com.squareup.workflow1 import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composition -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MonotonicFrameClock -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.mutableStateOf import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.launch -import kotlin.coroutines.EmptyCoroutineContext import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName import kotlin.reflect.KType @@ -99,37 +92,12 @@ public interface BaseRenderContext { * invalidated, this workflow will be re-rendered and the [content] recomposed to return its new * value. * - * @see ComposeWorkflow + * @see com.squareup.workflow1.compose.ComposeWorkflow */ public fun renderComposable( key: String = "", content: @WorkflowComposable @Composable () -> ChildRenderingT - ): ChildRenderingT { - val renderer: WorkflowComposableRenderer = TODO() - val frameClock: MonotonicFrameClock = TODO() - val coroutineContext = EmptyCoroutineContext + frameClock - val recomposer = Recomposer(coroutineContext) - val composition = Composition(UnitApplier, recomposer) - - // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to - // pump the dispatcher until the composition is finished. - CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { - try { - recomposer.runRecomposeAndApplyChanges() - } finally { - composition.dispose() - } - } - - val rendering = mutableStateOf(null) - composition.setContent { - CompositionLocalProvider(LocalWorkflowComposableRenderer provides renderer) { - rendering.value = content() - } - } - @Suppress("UNCHECKED_CAST") - return rendering.value as ChildRenderingT - } + ): ChildRenderingT /** * Ensures [sideEffect] is running with the given [key]. diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt deleted file mode 100644 index 281b20760..000000000 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposables.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.squareup.workflow1 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf - -internal val LocalWorkflowComposableRenderer = - staticCompositionLocalOf { error("No renderer") } - -internal interface WorkflowComposableRenderer { - @Composable - fun Child( - workflow: Workflow, - props: ChildPropsT, - onOutput: ((ChildOutputT) -> Unit)? - ): ChildRenderingT -} - -/** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param handler An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. - */ -@WorkflowComposable -@Composable -fun Child( - workflow: Workflow, - props: ChildPropsT, - onOutput: ((ChildOutputT) -> Unit)? -): ChildRenderingT { - val renderer = LocalWorkflowComposableRenderer.current - return renderer.Child(workflow, props, onOutput) -} - -@WorkflowComposable -@Composable -fun Child( - workflow: ComposeWorkflow, - props: ChildPropsT, - handler: ((ChildOutputT) -> Unit)? -): ChildRenderingT { - val childRendering = remember { mutableStateOf(null) } - // Since this function returns a value, it can't restart without also restarting its parent. - // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value - // actually changed. - IsolateRecomposeScope( - child = workflow, - props = props, - handler = handler, - result = childRendering - ) - @Suppress("UNCHECKED_CAST") - return childRendering.value as ChildRenderingT -} - -@Composable -private fun IsolateRecomposeScope( - child: ComposeWorkflow, - props: PropsT, - handler: ((OutputT) -> Unit)?, - result: MutableState, -) { - result.value = child.Rendering(props, handler ?: {}) -} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt similarity index 86% rename from workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt rename to workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt index 267d07ba9..f4c054c33 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/ComposeWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -1,8 +1,12 @@ -package com.squareup.workflow1 +package com.squareup.workflow1.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember +import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction /** * A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable. diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt similarity index 96% rename from workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt rename to workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt index a8e02afb7..98692fbaf 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowComposable.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1 +package com.squareup.workflow1.compose import androidx.compose.runtime.ComposableTargetMarker import kotlin.annotation.AnnotationRetention.BINARY diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt new file mode 100644 index 000000000..9986f88df --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt @@ -0,0 +1,98 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.Workflow + +/** + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or + * [BaseRenderContext.renderComposable]) and returns its rendering. + * + * @param onOutput An optional function that, if non-null, will be called when the child emits an + * output. If null, the child's outputs will be ignored. + */ +@WorkflowComposable +@Composable +fun renderChild( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? +): ChildRenderingT { + val host = LocalWorkflowCompositionHost.current + return host.renderChild(workflow, props, onOutput) +} + +/** + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or + * [BaseRenderContext.renderComposable]) and returns its rendering. + * + * @param onOutput An optional function that, if non-null, will be called when the child emits an + * output. If null, the child's outputs will be ignored. + */ +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, + props: ChildPropsT, +): ChildRenderingT = renderChild(workflow, props, onOutput = null) + +/** + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or + * [BaseRenderContext.renderComposable]) and returns its rendering. + * + * @param onOutput An optional function that, if non-null, will be called when the child emits an + * output. If null, the child's outputs will be ignored. + */ +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, + noinline onOutput: ((ChildOutputT) -> Unit)? +): ChildRenderingT = renderChild(workflow, props = Unit, onOutput) + +/** + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or + * [BaseRenderContext.renderComposable]) and returns its rendering. + * + * @param onOutput An optional function that, if non-null, will be called when the child emits an + * output. If null, the child's outputs will be ignored. + */ +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, +): ChildRenderingT = renderChild(workflow, Unit, onOutput = null) + +@WorkflowComposable +@Composable +fun renderChild( + workflow: ComposeWorkflow, + props: ChildPropsT, + handler: ((ChildOutputT) -> Unit)? +): ChildRenderingT { + val childRendering = remember { mutableStateOf(null) } + // Since this function returns a value, it can't restart without also restarting its parent. + // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value + // actually changed. + RecomposeScopeIsolator( + child = workflow, + props = props, + handler = handler, + result = childRendering + ) + @Suppress("UNCHECKED_CAST") + return childRendering.value as ChildRenderingT +} + +@Composable +private fun RecomposeScopeIsolator( + child: ComposeWorkflow, + props: PropsT, + handler: ((OutputT) -> Unit)?, + result: MutableState, +) { + result.value = child.Rendering(props, handler ?: {}) +} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt new file mode 100644 index 000000000..70a8c0444 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt @@ -0,0 +1,32 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import com.squareup.workflow1.Workflow + +// TODO @InternalWorkflowApi +public val LocalWorkflowCompositionHost: ProvidableCompositionLocal = + staticCompositionLocalOf { error("No WorkflowCompositionHost provided.") } + +/** + * Represents the owner of this [WorkflowComposable] composition. + */ +// TODO move these into a separate, internal-only, implementation-depended-on module to hide from +// consumers by default? +// TODO @InternalWorkflowApi +@Stable +public interface WorkflowCompositionHost { + + /** + * Renders a child [Workflow] and returns its rendering. See the top-level composable + * [com.squareup.workflow1.compose.renderChild] for main documentation. + */ + @Composable + public fun renderChild( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT +} diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index 0cea60e6e..ef7ac86a2 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -16,6 +16,13 @@ kotlin { if (targets == "kmp" || targets == "js") { js(IR) { browser() } } + sourceSets { + getByName("commonMain") { + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + } + } + } } dependencies { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt new file mode 100644 index 000000000..f1cd0bcd6 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt @@ -0,0 +1,52 @@ +package com.squareup.workflow1.internal + +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.RememberObserver +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.action +import kotlinx.coroutines.CoroutineScope + +internal class ComposedWorkflowChild( + compositeHashKey: Int, + private val coroutineScope: CoroutineScope, + private val compositionContext: CompositionContext, + private val recomposeScope: RecomposeScope +) : RememberObserver { + val workflowKey: String = "composed-workflow:${compositeHashKey.toString(radix = 16)}" + private var disposed = false + + var onOutput: ((ChildOutputT) -> Unit)? = null + val handler: (ChildOutputT) -> WorkflowAction = + { output -> + action(workflowKey) { + // This action is being applied to the composition host workflow, which we don't want to + // update at all. + // The onOutput callback instead will update any compose snapshot state required. + // Technically we could probably invoke it directly from the handler, not wait until the + // queued action is processed, but this ensures consistency with the rest of the workflow + // runtime: the callback won't fire before other callbacks ahead in the queue. + // We check disposed since a previous update may have caused a recomposition that removed + // this child from composition and since it doesn't have its own channel, we have to no-op. + if (!disposed) { + onOutput?.invoke(output) + } + + // TODO After invoking callback, send apply notifications and check if composition has any + // invalidations. Iff it does, then mark the current workflow node as needing re-render + // regardless of state change. + } + } + + override fun onAbandoned() { + onForgotten() + } + + override fun onRemembered() { + } + + override fun onForgotten() { + disposed = true + TODO("notify parent that we're gone") + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt index 9129bb638..ffe3cc23c 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Sink import com.squareup.workflow1.Workflow @@ -22,6 +23,11 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + + fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT } interface SideEffectRunner { @@ -62,6 +68,14 @@ internal class RealRenderContext( return renderer.render(child, props, key, handler) } + override fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT { + checkNotFrozen() + return renderer.renderComposable(key, content) + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 7ec3bd6ec..e756e64f2 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -1,5 +1,17 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberCoroutineScope import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -10,10 +22,16 @@ import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.compose.LocalWorkflowCompositionHost +import com.squareup.workflow1.compose.WorkflowCompositionHost import com.squareup.workflow1.identifier import com.squareup.workflow1.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * Responsible for tracking child workflows, starting them and tearing them down when necessary. @@ -97,7 +115,7 @@ internal class SubtreeManager( private val workflowSession: WorkflowSession? = null, private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, private val idCounter: IdCounter? = null -) : RealRenderContext.Renderer { +) : RealRenderContext.Renderer, WorkflowCompositionHost { private var children = ActiveStagingList>() /** @@ -144,6 +162,77 @@ internal class SubtreeManager( return stagedChild.render(child.asStatefulWorkflow(), props) } + override fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT { + val frameClock: MonotonicFrameClock + val coroutineContext = EmptyCoroutineContext + frameClock + val recomposer = Recomposer(coroutineContext) + val composition = Composition(UnitApplier, recomposer) + + // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to + // pump the dispatcher until the composition is finished. + CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { + try { + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + + val rendering = mutableStateOf(null) + composition.setContent { + CompositionLocalProvider(LocalWorkflowCompositionHost provides this) { + rendering.value = content() + } + } + + // TODO prime the first frame to generate the initial rendering + + @Suppress("UNCHECKED_CAST") + return rendering.value as ChildRenderingT + } + + @Composable + override fun renderChild( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT { + // Key on workflow so that we treat the caller passing in a different instance as a completely + // new render call and kill the old session. + // Don't need to key on this since the receiver can never change within a composition. + return key(workflow) { + val key = currentCompositeKeyHash + val coroutineScope = rememberCoroutineScope() + val compositionContext = rememberCompositionContext() + val recomposeScope = currentRecomposeScope + val child = remember { + ComposedWorkflowChild( + key, + coroutineScope, + compositionContext, + recomposeScope + ) + } + child.onOutput = onOutput + + // We need to be careful here that we don't change any state that we can't undo if the + // composition is abandoned. This should not update any state in the parent yet, just run + // (what should be) pure workflow methods and record which workflows we need to track or stop + // tracking. After the composition frame is finished, we can update the WorkflowNode state as + // required. + // TODO don't call render, it's not powerful enough for what we need. + render( + child = workflow, + props = props, + key = child.workflowKey, + handler = child.handler + ) + } + } + /** * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance * is managing. diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt similarity index 92% rename from workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt rename to workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt index 309a14cd0..d3ba559a6 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/UnitApplier.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1 +package com.squareup.workflow1.internal import androidx.compose.runtime.Applier From 409fd961aa92985bee362f3c7f2b814f6a31c29c Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Fri, 28 Feb 2025 09:28:01 -0800 Subject: [PATCH 3/5] made the API more elegant, lots more docs --- workflow-core/build.gradle.kts | 1 + .../squareup/workflow1/BaseRenderContext.kt | 15 +- .../workflow1/compose/ComposeWorkflow.kt | 218 ++++++++++++++++-- .../workflow1/compose/WorkflowComposable.kt | 2 + .../workflow1/compose/WorkflowComposables.kt | 109 ++++----- .../compose/WorkflowCompositionHost.kt | 6 +- .../workflow1/internal/RealRenderContext.kt | 3 + .../workflow1/internal/SubtreeManager.kt | 71 +++--- 8 files changed, 304 insertions(+), 121 deletions(-) diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index e34abadba..c123aae3e 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { // For Snapshot. commonMainApi(libs.squareup.okio) commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3") + commonMainApi("org.jetbrains.compose.runtime:runtime-saveable:1.7.3") commonTestImplementation(libs.kotlinx.atomicfu) commonTestImplementation(libs.kotlinx.coroutines.test.common) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index 47306c1f7..0a0fde87d 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -10,8 +10,11 @@ package com.squareup.workflow1 import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveable import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.compose.ComposeWorkflow import com.squareup.workflow1.compose.WorkflowComposable +import com.squareup.workflow1.compose.renderWorkflow import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -89,11 +92,17 @@ public interface BaseRenderContext { /** * Synchronously composes a [content] function and returns its rendering. Whenever [content] is - * invalidated, this workflow will be re-rendered and the [content] recomposed to return its new - * value. + * invalidated (i.e. a compose snapshot state object is changed that was previously read by + * [content] or any functions it calls), this workflow will be re-rendered and the relevant + * composables will be recomposed. * - * @see com.squareup.workflow1.compose.ComposeWorkflow + * To render child workflows from this method, call [renderWorkflow]. + * Any state saved using Compose's state restoration mechanism (e.g. [rememberSaveable]) will be + * saved and restored using the workflow snapshot mechanism. + * + * @see ComposeWorkflow */ + @WorkflowExperimentalApi public fun renderComposable( key: String = "", content: @WorkflowComposable @Composable () -> ChildRenderingT diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt index f4c054c33..2ef34bed9 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -1,56 +1,224 @@ package com.squareup.workflow1.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.squareup.workflow1.BaseRenderContext +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.compose.SampleComposeWorkflow.Rendering +import kotlinx.coroutines.flow.StateFlow /** - * A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable. + * A [Workflow]-like interface that participates in a workflow tree via its [produceRendering] + * composable. See the docs on [produceRendering] for more information on writing composable + * workflows. + * + * @sample SampleComposeWorkflow */ +@WorkflowExperimentalApi @Stable -public interface ComposeWorkflow< +public abstract class ComposeWorkflow< in PropsT, out OutputT, out RenderingT - > { + > : Workflow { /** * The main composable of this workflow that consumes some [props] from its parent and may emit - * an output via [emitOutput]. + * an output via [emitOutput]. Equivalent to [StatefulWorkflow.render]. * - * Equivalent to [StatefulWorkflow.render]. + * To render child workflows (composable or otherwise) from this method, call [renderWorkflow]. + * + * Any compose snapshot state that is read in this method or any methods it calls, that is later + * changed, will trigger a re-render of the workflow tree. See + * [BaseRenderContext.renderComposable] for more details on how composition is tied to the + * workflow lifecycle. + * + * To save state when the workflow tree is restored, use [rememberSaveable]. This is equivalent + * to implementing [StatefulWorkflow.snapshotState]. + * + * @param props The [PropsT] value passed in from the parent workflow. + * @param emitOutput A function that can be called to emit an [OutputT] value to the parent + * workflow. Calling this method is analogous to sending an action to + * [BaseRenderContext.actionSink] that calls + * [setOutput][com.squareup.workflow1.WorkflowAction.Updater.setOutput]. If this function is + * called from the `onOutput` callback of a [renderWorkflow], then it is equivalent to returning + * an action from [BaseRenderContext.renderChild]'s `handler` parameter. + * + * @sample SampleComposeWorkflow.produceRendering */ @WorkflowComposable @Composable - fun Rendering( + protected abstract fun produceRendering( props: PropsT, emitOutput: (OutputT) -> Unit ): RenderingT -} -fun < - PropsT, StateT, OutputT, - ChildPropsT, ChildOutputT, ChildRenderingT - > BaseRenderContext.renderChild( - child: ComposeWorkflow, - props: ChildPropsT, - key: String = "", - handler: (ChildOutputT) -> WorkflowAction -): ChildRenderingT = renderComposable(key = key) { - // Explicitly remember the output function since we know that actionSink is stable even though - // Compose might not know that. - val emitOutput: (ChildOutputT) -> Unit = remember(actionSink) { - { output -> - val action = handler(output) - actionSink.send(action) + /** + * Render this workflow as a child of another [WorkflowComposable], ensuring that the workflow's + * [produceRendering] method is a separate recompose scope from the caller. + */ + @Composable + internal fun renderWithRecomposeBoundary( + props: PropsT, + onOutput: ((OutputT) -> Unit)? + ): RenderingT { + // Since this function returns a value, it can't restart without also restarting its parent. + // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering + // value actually changed. + val renderingState = remember { mutableStateOf(null) } + RecomposeScopeIsolator( + props = props, + onOutput = onOutput, + result = renderingState + ) + + // The value is guaranteed to have been set at least once by RecomposeScopeIsolator so this cast + // will never fail. Note we can't use !! since RenderingT itself might nullable, so null is + // still a potentially valid rendering value. + @Suppress("UNCHECKED_CAST") + return renderingState.value as RenderingT + } + + /** + * Creates an isolated recompose scope that separates a non-restartable caller ([render]) from + * a non-restartable function call ([produceRendering]). This is accomplished simply by this + * function having a [Unit] return type and being not inline. + * + * **It MUST have a [Unit] return type to do its job.** + */ + @Composable + private fun RecomposeScopeIsolator( + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + result: MutableState, + ) { + result.value = produceRendering(props, onOutput ?: {}) + } + + private var statefulImplCache: ComposeWorkflowWrapper? = null + final override fun asStatefulWorkflow(): StatefulWorkflow = + statefulImplCache ?: ComposeWorkflowWrapper().also { statefulImplCache = it } + + /** + * Exposes this [ComposeWorkflow] as a [StatefulWorkflow]. + */ + private inner class ComposeWorkflowWrapper : + StatefulWorkflow() { + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ) { + // Noop + } + + override fun render( + renderProps: PropsT, + renderState: Unit, + context: RenderContext + ): RenderingT = context.renderComposable { + // Explicitly remember the output function since we know that actionSink is stable even though + // Compose might not know that. + val emitOutput: (OutputT) -> Unit = remember(context.actionSink) { + { output -> context.actionSink.send(OutputAction(output)) } + } + + // Since we're composing directly from renderComposable, we don't need to isolate the + // recompose boundary again. This root composable is already a recompose boundary, and we + // don't need to create a redundant rendering state holder. + return@renderComposable produceRendering( + props = renderProps, + emitOutput = emitOutput + ) } + + override fun snapshotState(state: Unit): Snapshot? = null + + private inner class OutputAction( + private val output: OutputT + ) : WorkflowAction() { + override fun Updater.apply() { + setOutput(output) + } + } + } +} + +@OptIn(WorkflowExperimentalApi::class) +private class SampleComposeWorkflow +// In real code, this constructor would probably be injected by Dagger or something. +constructor( + private val injectedService: Service, + private val child: Workflow +) : ComposeWorkflow< + /* PropsT */ String, + /* OutputT */ String, + /* RenderingT */ Rendering + >() { + + // In real code, this would not be defined in the workflow itself but somewhere else in the + // codebase. + interface Service { + val values: StateFlow } - child.Rendering( - props = props, - emitOutput = emitOutput + + data class Rendering( + val label: String, + val onClick: () -> Unit ) + + @Composable + override fun produceRendering( + props: String, + emitOutput: (String) -> Unit + ): Rendering { + // ComposeWorkflows use native compose idioms to manage state, including saving state to be + // restored later. + var clickCount by rememberSaveable { mutableIntStateOf(0) } + + // They also use native compose idioms to work with Flows and perform effects. + val serviceValue by injectedService.values.collectAsState() + + // And they can render child workflows, just like traditional workflows. This is equivalent to + // calling BaseRenderContext.renderChild(). + // Note that there's no explicit key: the child key is tied to where it's called in the + // composition, the same way other composable state is keyed. + val childRendering = renderWorkflow( + workflow = child, + props = "child props", + // This is equivalent to the handler parameter on renderChild(). + onOutput = { + emitOutput("child emitted output: $it") + } + ) + + return Rendering( + // Reading clickCount and serviceValue here mean that when those values are changed, it will + // trigger a render pass in the hosting workflow tree, which will recompose this method. + label = "props=$props, " + + "clickCount=$clickCount, " + + "serviceValue=$serviceValue, " + + "childRendering=$childRendering", + onClick = { + // Instead of using WorkflowAction's state property, you can just update snapshot state + // objects directly. + clickCount++ + + // This is equivalent to calling setOutput from a WorkflowAction. + emitOutput("clicked!") + } + ) + } } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt index 98692fbaf..67a5885ca 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1.compose import androidx.compose.runtime.ComposableTargetMarker +import com.squareup.workflow1.WorkflowExperimentalApi import kotlin.annotation.AnnotationRetention.BINARY import kotlin.annotation.AnnotationTarget.FILE import kotlin.annotation.AnnotationTarget.FUNCTION @@ -17,6 +18,7 @@ import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER * the necessary equivalent annotations automatically. See * [androidx.compose.runtime.ComposableTarget] for details. */ +@WorkflowExperimentalApi @ComposableTargetMarker(description = "Workflow Composable") @Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER) @Retention(BINARY) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt index 9986f88df..3cc6bc6d3 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt @@ -1,98 +1,83 @@ package com.squareup.workflow1.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.key import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. + * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a + * [ComposeWorkflow.produceRendering] or [BaseRenderContext.renderComposable]) and returns its + * rendering. + * + * This method supports rendering any [Workflow] type, including [ComposeWorkflow]s. If [workflow] + * is a [ComposeWorkflow] then it is composed directly without a detour to the traditional workflow + * system. + * + * Note that there's no `key` parameter: Child workflows are keyed by their position (where they're + * called from) in the composition and the identity of the workflow itself, in the same way that + * composables themselves are keyed. * * @param onOutput An optional function that, if non-null, will be called when the child emits an * output. If null, the child's outputs will be ignored. */ +// TODO should these be extension functions? +@WorkflowExperimentalApi @WorkflowComposable @Composable -fun renderChild( +fun renderWorkflow( workflow: Workflow, props: ChildPropsT, onOutput: ((ChildOutputT) -> Unit)? -): ChildRenderingT { - val host = LocalWorkflowCompositionHost.current - return host.renderChild(workflow, props, onOutput) -} +): ChildRenderingT = +// We need to key on workflow so that all the state associated with the workflow is moved or removed +// with that particular instance of the workflow. E.g. if a single renderWorkflow call is passed +// a workflow from props, and the workflow changes, then all the state from the old session should +// be removed and replaced with completely new state for the new workflow. This matches how normal + // renderChild calls work. + key(workflow) { + if (workflow is ComposeWorkflow) { + // Don't need to jump out into non-workflow world if the workflow is already composable. + workflow.renderWithRecomposeBoundary(props, onOutput) + } else { + val host = LocalWorkflowCompositionHost.current + host.renderChild(workflow, props, onOutput) + } + } /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no output (`OutputT` is [Nothing]). + * For more documentation see [renderWorkflow]. */ +@WorkflowExperimentalApi @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, props: ChildPropsT, -): ChildRenderingT = renderChild(workflow, props, onOutput = null) +): ChildRenderingT = renderWorkflow(workflow, props, onOutput = null) /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no props (`PropsT` is [Unit]). + * For more documentation see [renderWorkflow]. */ +@WorkflowExperimentalApi @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, noinline onOutput: ((ChildOutputT) -> Unit)? -): ChildRenderingT = renderChild(workflow, props = Unit, onOutput) +): ChildRenderingT = renderWorkflow(workflow, props = Unit, onOutput) /** - * Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or - * [BaseRenderContext.renderComposable]) and returns its rendering. - * - * @param onOutput An optional function that, if non-null, will be called when the child emits an - * output. If null, the child's outputs will be ignored. + * Renders a child [Workflow] that has no props or output (`PropsT` is [Unit], `OutputT` is + * [Nothing]). + * For more documentation see [renderWorkflow]. */ +@WorkflowExperimentalApi @WorkflowComposable @Composable -inline fun renderChild( +inline fun renderWorkflow( workflow: Workflow, -): ChildRenderingT = renderChild(workflow, Unit, onOutput = null) - -@WorkflowComposable -@Composable -fun renderChild( - workflow: ComposeWorkflow, - props: ChildPropsT, - handler: ((ChildOutputT) -> Unit)? -): ChildRenderingT { - val childRendering = remember { mutableStateOf(null) } - // Since this function returns a value, it can't restart without also restarting its parent. - // IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value - // actually changed. - RecomposeScopeIsolator( - child = workflow, - props = props, - handler = handler, - result = childRendering - ) - @Suppress("UNCHECKED_CAST") - return childRendering.value as ChildRenderingT -} - -@Composable -private fun RecomposeScopeIsolator( - child: ComposeWorkflow, - props: PropsT, - handler: ((OutputT) -> Unit)?, - result: MutableState, -) { - result.value = child.Rendering(props, handler ?: {}) -} +): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt index 70a8c0444..f3c53aa74 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt @@ -5,8 +5,10 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable import androidx.compose.runtime.staticCompositionLocalOf import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi // TODO @InternalWorkflowApi +@WorkflowExperimentalApi public val LocalWorkflowCompositionHost: ProvidableCompositionLocal = staticCompositionLocalOf { error("No WorkflowCompositionHost provided.") } @@ -16,13 +18,15 @@ public val LocalWorkflowCompositionHost: ProvidableCompositionLocal renderChild( workflow: Workflow, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt index ffe3cc23c..5a275b0a4 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt @@ -1,3 +1,5 @@ +@file:OptIn(WorkflowExperimentalApi::class) + package com.squareup.workflow1.internal import androidx.compose.runtime.Composable @@ -5,6 +7,7 @@ import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Sink import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowTracer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index e756e64f2..37681944a 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -1,3 +1,5 @@ +@file:OptIn(WorkflowExperimentalApi::class) + package com.squareup.workflow1.internal import androidx.compose.runtime.Composable @@ -12,6 +14,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -19,6 +23,7 @@ import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer @@ -166,10 +171,11 @@ internal class SubtreeManager( key: String, content: @Composable () -> ChildRenderingT ): ChildRenderingT { - val frameClock: MonotonicFrameClock + val frameClock: MonotonicFrameClock // TODO val coroutineContext = EmptyCoroutineContext + frameClock val recomposer = Recomposer(coroutineContext) val composition = Composition(UnitApplier, recomposer) + val saveableStateRegistry: SaveableStateRegistry // TODO // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to // pump the dispatcher until the composition is finished. @@ -183,7 +189,10 @@ internal class SubtreeManager( val rendering = mutableStateOf(null) composition.setContent { - CompositionLocalProvider(LocalWorkflowCompositionHost provides this) { + CompositionLocalProvider( + LocalWorkflowCompositionHost provides this, + LocalSaveableStateRegistry provides saveableStateRegistry, + ) { rendering.value = content() } } @@ -200,37 +209,39 @@ internal class SubtreeManager( props: ChildPropsT, onOutput: ((ChildOutputT) -> Unit)? ): ChildRenderingT { - // Key on workflow so that we treat the caller passing in a different instance as a completely - // new render call and kill the old session. - // Don't need to key on this since the receiver can never change within a composition. - return key(workflow) { - val key = currentCompositeKeyHash - val coroutineScope = rememberCoroutineScope() - val compositionContext = rememberCompositionContext() - val recomposeScope = currentRecomposeScope - val child = remember { - ComposedWorkflowChild( - key, - coroutineScope, - compositionContext, - recomposeScope - ) - } - child.onOutput = onOutput + // We need to key on workflow so that we treat the caller passing in a different instance as a + // completely new render call and kill the old session. This keying is done in the + // renderWorkflow function that is the public entry point to this function so we don't need to + // do it again here. + // We don't need to key on `this` since the subtree manager will never change during a workflow + // session. - // We need to be careful here that we don't change any state that we can't undo if the - // composition is abandoned. This should not update any state in the parent yet, just run - // (what should be) pure workflow methods and record which workflows we need to track or stop - // tracking. After the composition frame is finished, we can update the WorkflowNode state as - // required. - // TODO don't call render, it's not powerful enough for what we need. - render( - child = workflow, - props = props, - key = child.workflowKey, - handler = child.handler + val key = currentCompositeKeyHash + val coroutineScope = rememberCoroutineScope() + val compositionContext = rememberCompositionContext() + val recomposeScope = currentRecomposeScope + val child = remember { + ComposedWorkflowChild( + key, + coroutineScope, + compositionContext, + recomposeScope ) } + child.onOutput = onOutput + + // We need to be careful here that we don't change any state that we can't undo if the + // composition is abandoned. This should not update any state in the parent yet, just run + // (what should be) pure workflow methods and record which workflows we need to track or stop + // tracking. After the composition frame is finished, we can update the WorkflowNode state as + // required. + // TODO don't call render, it's not powerful enough for what we need. + render( + child = workflow, + props = props, + key = child.workflowKey, + handler = child.handler + ) } /** From d24e37a0fbbf6a90ede864655941dfc55057f10e Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Wed, 5 Mar 2025 09:04:48 -0800 Subject: [PATCH 4/5] sketch out CompositionLocal propagation --- .../workflow1/compose/WorkflowComposables.kt | 4 +++ .../internal/ComposedWorkflowChild.kt | 6 ++-- .../workflow1/internal/SubtreeManager.kt | 34 +++++++++++++------ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt index 3cc6bc6d3..f97ad932b 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt @@ -46,6 +46,8 @@ fun renderWorkflow( } } +// region Convenience overloads for specific type arguments + /** * Renders a child [Workflow] that has no output (`OutputT` is [Nothing]). * For more documentation see [renderWorkflow]. @@ -81,3 +83,5 @@ inline fun renderWorkflow( inline fun renderWorkflow( workflow: Workflow, ): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null) + +// endregion diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt index f1cd0bcd6..226992be5 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt @@ -1,6 +1,6 @@ package com.squareup.workflow1.internal -import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.RecomposeScope import androidx.compose.runtime.RememberObserver import com.squareup.workflow1.WorkflowAction @@ -10,8 +10,8 @@ import kotlinx.coroutines.CoroutineScope internal class ComposedWorkflowChild( compositeHashKey: Int, private val coroutineScope: CoroutineScope, - private val compositionContext: CompositionContext, - private val recomposeScope: RecomposeScope + private val recomposeScope: RecomposeScope, + private val localsContext: CompositionLocalContext, ) : RememberObserver { val workflowKey: String = "composed-workflow:${compositeHashKey.toString(radix = 16)}" private var disposed = false diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 37681944a..b60c5906d 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -4,15 +4,15 @@ package com.squareup.workflow1.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.Recomposer import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.currentCompositionLocalContext import androidx.compose.runtime.currentRecomposeScope -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.LocalSaveableStateRegistry import androidx.compose.runtime.saveable.SaveableStateRegistry @@ -176,6 +176,7 @@ internal class SubtreeManager( val recomposer = Recomposer(coroutineContext) val composition = Composition(UnitApplier, recomposer) val saveableStateRegistry: SaveableStateRegistry // TODO + val localsContext: CompositionLocalContext? // TODO // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to // pump the dispatcher until the composition is finished. @@ -188,7 +189,7 @@ internal class SubtreeManager( } val rendering = mutableStateOf(null) - composition.setContent { + val wrappedContent = @Composable { CompositionLocalProvider( LocalWorkflowCompositionHost provides this, LocalSaveableStateRegistry provides saveableStateRegistry, @@ -197,6 +198,19 @@ internal class SubtreeManager( } } + composition.setContent { + // Must provide the locals from the parent composition first so we can override the ones we + // need. If it's null then there's no parent, but the CompositionLocalProvider API has no nice + // way to pass nothing in this overload. I believe it's safe to actually call content through + // two different code paths because whether there's a parent composition cannot change for an + // existing workflow session – they can't move. + if (localsContext == null) { + wrappedContent() + } else { + CompositionLocalProvider(localsContext, wrappedContent) + } + } + // TODO prime the first frame to generate the initial rendering @Suppress("UNCHECKED_CAST") @@ -218,14 +232,14 @@ internal class SubtreeManager( val key = currentCompositeKeyHash val coroutineScope = rememberCoroutineScope() - val compositionContext = rememberCompositionContext() + val localsContext = currentCompositionLocalContext val recomposeScope = currentRecomposeScope val child = remember { - ComposedWorkflowChild( - key, - coroutineScope, - compositionContext, - recomposeScope + ComposedWorkflowChild( + compositeHashKey = key, + coroutineScope = coroutineScope, + recomposeScope = recomposeScope, + localsContext = localsContext, ) } child.onOutput = onOutput @@ -236,7 +250,7 @@ internal class SubtreeManager( // tracking. After the composition frame is finished, we can update the WorkflowNode state as // required. // TODO don't call render, it's not powerful enough for what we need. - render( + return render( child = workflow, props = props, key = child.workflowKey, From 6194650a5acdd7f0d7fce62bfd4137e589b24747 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Mon, 10 Mar 2025 12:08:45 -0700 Subject: [PATCH 5/5] move renderComposable state into dedicated class --- .../squareup/workflow1/WorkflowInterceptor.kt | 25 +++++++ .../workflow1/internal/SubtreeManager.kt | 64 ++-------------- .../internal/WorkflowComposableNode.kt | 74 +++++++++++++++++++ 3 files changed, 107 insertions(+), 56 deletions(-) create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 637615650..5cf0e33e5 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -259,6 +260,15 @@ public interface WorkflowInterceptor { handler: (CO) -> WorkflowAction ) -> CR ): CR = proceed(child, childProps, key, handler) + + public fun onRenderComposable( + key: String, + content: @Composable () -> CR, + proceed: ( + key: String, + content: @Composable () -> CR + ) -> CR + ): CR = proceed(key, content) } } @@ -384,6 +394,21 @@ private class InterceptedRenderContext( } } + @OptIn(WorkflowExperimentalApi::class) + override fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT = interceptor.onRenderComposable( + key = key, + content = content, + proceed = { iKey, iContent -> + baseRenderContext.renderComposable( + key = iKey, + content = iContent + ) + } + ) + /** * In a block with a CoroutineScope receiver, calls to `coroutineContext` bind * to `CoroutineScope.coroutineContext` instead of `suspend val coroutineContext`. diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index b60c5906d..8f05f3ce4 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -3,19 +3,11 @@ package com.squareup.workflow1.internal import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composition -import androidx.compose.runtime.CompositionLocalContext -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MonotonicFrameClock -import androidx.compose.runtime.Recomposer import androidx.compose.runtime.currentCompositeKeyHash import androidx.compose.runtime.currentCompositionLocalContext import androidx.compose.runtime.currentRecomposeScope -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.SaveableStateRegistry import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -27,16 +19,11 @@ import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer -import com.squareup.workflow1.compose.LocalWorkflowCompositionHost import com.squareup.workflow1.compose.WorkflowCompositionHost import com.squareup.workflow1.identifier import com.squareup.workflow1.trace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.launch import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * Responsible for tracking child workflows, starting them and tearing them down when necessary. @@ -171,50 +158,15 @@ internal class SubtreeManager( key: String, content: @Composable () -> ChildRenderingT ): ChildRenderingT { - val frameClock: MonotonicFrameClock // TODO - val coroutineContext = EmptyCoroutineContext + frameClock - val recomposer = Recomposer(coroutineContext) - val composition = Composition(UnitApplier, recomposer) - val saveableStateRegistry: SaveableStateRegistry // TODO - val localsContext: CompositionLocalContext? // TODO - - // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to - // pump the dispatcher until the composition is finished. - CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { - try { - recomposer.runRecomposeAndApplyChanges() - } finally { - composition.dispose() - } - } - - val rendering = mutableStateOf(null) - val wrappedContent = @Composable { - CompositionLocalProvider( - LocalWorkflowCompositionHost provides this, - LocalSaveableStateRegistry provides saveableStateRegistry, - ) { - rendering.value = content() - } - } - - composition.setContent { - // Must provide the locals from the parent composition first so we can override the ones we - // need. If it's null then there's no parent, but the CompositionLocalProvider API has no nice - // way to pass nothing in this overload. I believe it's safe to actually call content through - // two different code paths because whether there's a parent composition cannot change for an - // existing workflow session – they can't move. - if (localsContext == null) { - wrappedContent() - } else { - CompositionLocalProvider(localsContext, wrappedContent) - } - } - - // TODO prime the first frame to generate the initial rendering + // TODO initialize, store, and start the node from an ActiveStagingList + val node = WorkflowComposableNode( + frameClock = TODO(), + saveableStateRegistry = TODO(), + localsContext = TODO("get from parent somehow") + ) + node.start() - @Suppress("UNCHECKED_CAST") - return rendering.value as ChildRenderingT + node.render(content) } @Composable diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt new file mode 100644 index 000000000..52489690e --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt @@ -0,0 +1,74 @@ +package com.squareup.workflow1.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import com.squareup.workflow1.WorkflowExperimentalApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +@OptIn(WorkflowExperimentalApi::class) +internal class WorkflowComposableNode( + private val frameClock: MonotonicFrameClock, // TODO + coroutineContext: CoroutineContext = EmptyCoroutineContext, + private val saveableStateRegistry: SaveableStateRegistry, // TODO + private val localsContext: CompositionLocalContext?, // TODO +) { + private val coroutineContext = coroutineContext + frameClock + private val recomposer: Recomposer = Recomposer(coroutineContext) + private val composition: Composition = Composition(UnitApplier, recomposer) + private val rendering = mutableStateOf(null) + + fun start() { + // TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to + // pump the dispatcher until the composition is finished. + CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) { + try { + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + } + + fun render(content: @Composable () -> RenderingT): RenderingT { + composition.setContent { + // Must provide the locals from the parent composition first so we can override the ones we + // need. If it's null then there's no parent, but the CompositionLocalProvider API has no nice + // way to pass nothing in this overload. I believe it's safe to actually call content through + // two different code paths because whether there's a parent composition cannot change for an + // existing workflow session – they can't move. + if (localsContext == null) { + LocalsProvider(content) + } else { + CompositionLocalProvider(localsContext) { + LocalsProvider(content) + } + } + } + + // TODO prime the first frame to generate the initial rendering + + @Suppress("UNCHECKED_CAST") + return rendering.value as RenderingT + } + + @Composable + private inline fun LocalsProvider(crossinline content: @Composable () -> RenderingT) { + CompositionLocalProvider( + // LocalWorkflowCompositionHost provides this, + LocalSaveableStateRegistry provides saveableStateRegistry, + ) { + rendering.value = content() + } + } +}