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..c123aae3e 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,8 @@ dependencies { commonMainApi(libs.kotlinx.coroutines.core) // 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 e26614557..0a0fde87d 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -9,7 +9,12 @@ 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 @@ -85,6 +90,24 @@ public interface BaseRenderContext { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + /** + * Synchronously composes a [content] function and returns its rendering. Whenever [content] is + * 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. + * + * 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 + ): ChildRenderingT + /** * Ensures [sideEffect] is running with the given [key]. * 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 new file mode 100644 index 000000000..2ef34bed9 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -0,0 +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 [produceRendering] + * composable. See the docs on [produceRendering] for more information on writing composable + * workflows. + * + * @sample SampleComposeWorkflow + */ +@WorkflowExperimentalApi +@Stable +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]. 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 + protected abstract fun produceRendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT + + /** + * 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 + } + + 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 new file mode 100644 index 000000000..67a5885ca --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt @@ -0,0 +1,25 @@ +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 +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. + */ +@WorkflowExperimentalApi +@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/compose/WorkflowComposables.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt new file mode 100644 index 000000000..f97ad932b --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposables.kt @@ -0,0 +1,87 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +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.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 renderWorkflow( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? +): 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) + } + } + +// region Convenience overloads for specific type arguments + +/** + * Renders a child [Workflow] that has no output (`OutputT` is [Nothing]). + * For more documentation see [renderWorkflow]. + */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderWorkflow( + workflow: Workflow, + props: ChildPropsT, +): ChildRenderingT = renderWorkflow(workflow, props, onOutput = null) + +/** + * Renders a child [Workflow] that has no props (`PropsT` is [Unit]). + * For more documentation see [renderWorkflow]. + */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderWorkflow( + workflow: Workflow, + noinline onOutput: ((ChildOutputT) -> Unit)? +): ChildRenderingT = renderWorkflow(workflow, props = Unit, onOutput) + +/** + * 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 renderWorkflow( + workflow: Workflow, +): ChildRenderingT = renderWorkflow(workflow, Unit, onOutput = null) + +// endregion 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..f3c53aa74 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowCompositionHost.kt @@ -0,0 +1,36 @@ +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 +import com.squareup.workflow1.WorkflowExperimentalApi + +// TODO @InternalWorkflowApi +@WorkflowExperimentalApi +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 +@WorkflowExperimentalApi +@Stable +public interface WorkflowCompositionHost { + + /** + * Renders a child [Workflow] and returns its rendering. See the top-level composable + * [com.squareup.workflow1.compose.renderWorkflow] for main documentation. + */ + @WorkflowComposable + @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/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/ComposedWorkflowChild.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposedWorkflowChild.kt new file mode 100644 index 000000000..226992be5 --- /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.CompositionLocalContext +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 recomposeScope: RecomposeScope, + private val localsContext: CompositionLocalContext, +) : 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..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,9 +1,13 @@ +@file:OptIn(WorkflowExperimentalApi::class) + 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 import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowTracer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel @@ -22,6 +26,11 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + + fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT } interface SideEffectRunner { @@ -62,6 +71,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..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 @@ -1,5 +1,13 @@ +@file:OptIn(WorkflowExperimentalApi::class) + package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -7,9 +15,11 @@ 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 +import com.squareup.workflow1.compose.WorkflowCompositionHost import com.squareup.workflow1.identifier import com.squareup.workflow1.trace import kotlinx.coroutines.selects.SelectBuilder @@ -97,7 +107,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 +154,62 @@ internal class SubtreeManager( return stagedChild.render(child.asStatefulWorkflow(), props) } + override fun renderComposable( + key: String, + content: @Composable () -> ChildRenderingT + ): ChildRenderingT { + // 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() + + node.render(content) + } + + @Composable + override fun renderChild( + workflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT { + // 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. + + val key = currentCompositeKeyHash + val coroutineScope = rememberCoroutineScope() + val localsContext = currentCompositionLocalContext + val recomposeScope = currentRecomposeScope + val child = remember { + ComposedWorkflowChild( + compositeHashKey = key, + coroutineScope = coroutineScope, + recomposeScope = recomposeScope, + localsContext = localsContext, + ) + } + 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. + return 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-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt new file mode 100644 index 000000000..d3ba559a6 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt @@ -0,0 +1,42 @@ +package com.squareup.workflow1.internal + +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-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() + } + } +}