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