From 40cb5ffcef6647a656d31d2a6fb1c790404a3b80 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Tue, 25 Feb 2025 18:04:49 -0800 Subject: [PATCH 1/2] Introduce renderComposable. --- .../NestedRenderingsActivity.kt | 4 +- settings.gradle.kts | 1 + workflow-core/build.gradle.kts | 3 + .../squareup/workflow1/BaseRenderContext.kt | 37 ++++ .../workflow1/compose/WorkflowComposable.kt | 25 +++ workflow-runtime/build.gradle.kts | 8 + .../SimpleLoggingWorkflowInterceptor.kt | 11 ++ .../squareup/workflow1/WorkflowInterceptor.kt | 28 +++ .../workflow1/internal/RealRenderContext.kt | 20 +++ .../workflow1/internal/RecomposeAction.kt | 13 ++ .../workflow1/internal/SubtreeManager.kt | 49 +++++- .../workflow1/internal/UnitApplier.kt | 42 +++++ .../internal/WorkflowComposableNode.kt | 161 ++++++++++++++++++ .../workflow1/internal/WorkflowNode.kt | 9 +- 14 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RecomposeAction.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/UnitApplier.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt index 387385006..1c7b2fbd0 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering @@ -47,7 +48,8 @@ class NestedRenderingsActivity : AppCompatActivity() { workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) }, scope = viewModelScope, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), + interceptors = listOf(SimpleLoggingWorkflowInterceptor()) ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index f4982b30f..088a6fbc4 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 c3c841f41..cbe6875d8 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 8c863b548..aa520070d 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -8,7 +8,9 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -86,6 +88,27 @@ 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. + // * + // * The `emitOutput` function passed to [content] should be used to trigger [WorkflowAction]s in + // * this workflow via [handler]. Every invocation of `emitOutput` will result [handler]s action + // * being sent to this context's [actionSink]. However, it's important for the composable never to + // * send to [actionSink] directly because we need to ensure that any state writes the composable + // * does invalidate their composables before sending into the [actionSink]. + // */ + // @WorkflowExperimentalApi + // public fun renderComposable( + // key: String = "", + // handler: (ChildOutputT) -> WorkflowAction, + // content: @WorkflowComposable @Composable ( + // emitOutput: (ChildOutputT) -> Unit + // ) -> ChildRenderingT + // ): ChildRenderingT + /** * Ensures [sideEffect] is running with the given [key]. * @@ -209,6 +232,20 @@ public fun key: String = "" ): ChildRenderingT = renderChild(child, Unit, key) { noAction() } +/** + * TODO + */ +@WorkflowExperimentalApi +public fun + BaseRenderContext.renderComposable( + key: String = "", + content: @WorkflowComposable @Composable () -> ChildRenderingT +): ChildRenderingT = renderComposable( + key = key, + handler = { noAction() }, + content = { content() } +) + /** * Ensures a [LifecycleWorker] is running. Since [worker] can't emit anything, * it can't trigger any [WorkflowAction]s. 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..77aef796e --- /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) +public annotation class WorkflowComposable diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index d789bda88..35bee147a 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/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 { @@ -16,6 +17,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/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 58bb7af70..5e4b74638 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.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 @@ -150,5 +151,15 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { } } } + + override fun onRenderComposable( + key: String, + content: @Composable () -> CR, + proceed: (key: String, content: @Composable () -> CR) -> CR + ): CR = proceed(key) { + logMethod("onRenderComposable", session, "key" to key, "content" to content) { + content() + } + } } } 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 c1a598328..992c033ba 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,7 +1,9 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext @@ -322,6 +324,15 @@ public interface WorkflowInterceptor { calculation: () -> CResult ) -> CResult ): CResult = proceed(key, resultType, inputs, calculation) + + public fun onRenderComposable( + key: String, + content: @Composable (CO) -> CR, + proceed: ( + key: String, + content: @Composable (CO) -> CR + ) -> CR + ): CR = proceed(key, content) } } @@ -459,6 +470,23 @@ private class InterceptedRenderContext( } } + @OptIn(WorkflowExperimentalApi::class) + override fun renderComposable( + key: String, + handler: (ChildOutputT) -> WorkflowAction, + content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + ): ChildRenderingT = interceptor.onRenderComposable( + key = key, + content = content, + proceed = { iKey, iContent -> + baseRenderContext.renderComposable( + key = iKey, + handler = handler, + 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/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt index 4a7291199..22d13e11b 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,11 +1,16 @@ +@file:OptIn(WorkflowExperimentalApi::class) + package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.RuntimeConfig 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 com.squareup.workflow1.compose.WorkflowComposable import com.squareup.workflow1.identifier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel @@ -27,6 +32,12 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + + fun renderComposable( + key: String, + handler: (ChildOutputT) -> WorkflowAction, + content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + ): ChildRenderingT } interface SideEffectRunner { @@ -78,6 +89,15 @@ internal class RealRenderContext( return renderer.render(child, props, key, handler) } + override fun renderComposable( + key: String, + handler: (ChildOutputT) -> WorkflowAction, + content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + ): ChildRenderingT { + checkNotFrozen() + return renderer.renderComposable(key, handler, content) + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RecomposeAction.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RecomposeAction.kt new file mode 100644 index 000000000..228a19da4 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RecomposeAction.kt @@ -0,0 +1,13 @@ +package com.squareup.workflow1.internal + +import com.squareup.workflow1.WorkflowAction + +/** + * This action doesn't actually update state, but it's special-cased inside WorkflowNode to always + * act like it updated state, to force a re-render and thus a recomposition. + */ +internal class RecomposeAction : WorkflowAction() { + override fun Updater.apply() { + // Noop + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 09fb7608a..6f2225346 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,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -90,15 +91,18 @@ internal class SubtreeManager( private val contextForChildren: CoroutineContext, private val emitActionToParent: ( action: WorkflowAction, - childResult: ActionApplied<*> + childResult: ActionApplied<*>? ) -> ActionProcessingResult, private val runtimeConfig: RuntimeConfig, private val workflowTracer: WorkflowTracer?, private val workflowSession: WorkflowSession? = null, private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, - private val idCounter: IdCounter? = null + private val idCounter: IdCounter? = null, + private val requestRerender: () -> Unit = {}, + private val sendActionFromComposable: (WorkflowAction) -> Unit ) : RealRenderContext.Renderer { private var children = ActiveStagingList>() + private var composables = ActiveStagingList>() /** * Moves all the nodes that have been accumulated in the staging list to the active list, making @@ -112,6 +116,7 @@ internal class SubtreeManager( children.commitStaging { child -> child.workflowNode.cancel() } + composables.commitStaging(onRemove = WorkflowComposableNode<*, *, *, *, *>::dispose) // Get rid of any snapshots that weren't applied on the first render pass. // They belong to children that were saved but not restarted. snapshotCache = null @@ -145,6 +150,30 @@ internal class SubtreeManager( return stagedChild.render(child.asStatefulWorkflow(), props) } + override fun renderComposable( + key: String, + handler: (ChildOutputT) -> WorkflowAction, + content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + ): ChildRenderingT { + // Prevent duplicate workflows with the same key. + workflowTracer.trace("CheckingUniqueMatchesComposable") { + composables.forEachStaging { + require(key != it.workflowKey) { + "Expected keys to be unique for composable: key=\"$key\"" + } + } + } + + val stagedComposable = workflowTracer.trace("RetainingComposables") { + composables.retainOrCreate( + predicate = { it.workflowKey == key }, + create = { createComposableNode(key, handler) } + ) + } + stagedComposable.setHandler(handler) + return stagedComposable.render(content) + } + /** * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance * is managing. @@ -165,6 +194,7 @@ internal class SubtreeManager( val snapshots = mutableMapOf() children.forEachActive { child -> val childWorkflow = child.workflow.asStatefulWorkflow() + // Skip children who aren't snapshottable. snapshots[child.id] = child.workflowNode.snapshot(childWorkflow) } return snapshots @@ -206,4 +236,19 @@ internal class SubtreeManager( return WorkflowChildNode(child, handler, workflowNode) .also { node = it } } + + private fun createComposableNode( + key: String, + handler: (ChildOutputT) -> WorkflowAction, + ): WorkflowComposableNode { + return WorkflowComposableNode( + workflowKey = key, + handler = handler, + coroutineContext = contextForChildren, + requestRerender = requestRerender, + sendAction = sendActionFromComposable, + ).also { + it.start() + } + } } 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..4bc0d14b2 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt @@ -0,0 +1,161 @@ +package com.squareup.workflow1.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.internal.InlineLinkedList.InlineListNode +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.yield +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.resume + +internal class WorkflowComposableNode< + OutputT, + RenderingT, + ParentPropsT, + ParentStateT, + ParentOutputT + >( + val workflowKey: String, + private var handler: (OutputT) -> WorkflowAction, + private val requestRerender: () -> Unit, + private val sendAction: (WorkflowAction) -> Unit, + coroutineContext: CoroutineContext = EmptyCoroutineContext, +) : InlineListNode>, MonotonicFrameClock { + + companion object { + private fun log(message: String) = message.lines().forEach { + println("WorkflowComposableNode $it") + } + } + + override var nextListNode: WorkflowComposableNode<*, *, *, *, *>? = null + + private val coroutineContext = coroutineContext + this + private val recomposer: Recomposer = Recomposer(coroutineContext) + private val composition: Composition = Composition(UnitApplier, recomposer) + private val rendering = mutableStateOf(null) + private var frameRequest: FrameRequest<*>? = null + private var frameTimeCounter = 0L + private val emitOutput: (OutputT) -> Unit = { output -> + // TODO set flag in WFNode saying that it will re-render imminently. + + // Allow the function calling this one to finish doing any state updates before triggering + // a rerender. + post { + // Ensure any state updates performed by the caller get to invalidate any compositions that + // read them. If the dispatcher is Main.immediate, this will synchronously call + // withFrameNanos, so that needs to check the flag we set above. + Snapshot.sendApplyNotifications() + // If dispatcher is Main.immediate this will synchronously perform re-render. + sendAction(handler(output)) + } + } + + private fun post(action: () -> Unit) { + CoroutineScope(coroutineContext).launch { + val dispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher + if (dispatcher?.isDispatchNeeded(coroutineContext) != true) { + // TODO verify this actually posts to the main thread on Main.immediate + yield() + } + action() + } + } + + 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 { + log("runRecomposeAndApplyChanges") + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + } + + fun dispose() { + recomposer.cancel() + } + + /** + * Updates the handler function that will be invoked by [acceptChildOutput]. + */ + fun setHandler(newHandler: (CO) -> WorkflowAction) { + @Suppress("UNCHECKED_CAST") + handler = newHandler as (OutputT) -> WorkflowAction + } + + /** + * Has a separate type parameter to allow type erasure. + */ + fun render(content: @Composable (emitOutput: (O) -> Unit) -> R): R { + log("render setting content") + log(RuntimeException().stackTraceToString()) + composition.setContent { + @Suppress("UNCHECKED_CAST") + rendering.value = content(emitOutput as (O) -> Unit) as RenderingT + } + + val frameRequest = this.frameRequest + if (frameRequest != null) { + this.frameRequest = null + val frameTime = frameTimeCounter++ + log("render executing frame with time $frameTime") + frameRequest.execute(frameTime) + log("render finished executing frame with time $frameTime") + } else { + log( + "render no frame request, skipping recomposition " + + "(hasInvalidations=${composition.hasInvalidations})" + ) + } + + log("render returning value: ${rendering.value}") + @Suppress("UNCHECKED_CAST") + return rendering.value as R + } + + /** + * Wrapper around [handler] that allows calling it with erased types. + */ + @Suppress("UNCHECKED_CAST") + fun acceptChildOutput(output: Any?): WorkflowAction = + handler(output as OutputT) + + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + check(frameRequest == null) { "Frame already requested" } + log("withFrameNanos") + log(RuntimeException().stackTraceToString()) + return suspendCancellableCoroutine { continuation -> + frameRequest = FrameRequest( + onFrame = onFrame, + continuation = continuation + ) + requestRerender() + } + } +} + +private class FrameRequest( + private val onFrame: (frameTimeNanos: Long) -> R, + private val continuation: CancellableContinuation +) { + fun execute(frameTimeNanos: Long) { + val result = onFrame(frameTimeNanos) + continuation.resume(result) + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index 04c680fb2..2a2220955 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -79,6 +79,8 @@ internal class WorkflowNode( private var cachedWorkflowInstance: StatefulWorkflow private var interceptedWorkflowInstance: StatefulWorkflow + private val eventActionsChannel = + Channel>(capacity = UNLIMITED) private val subtreeManager = SubtreeManager( snapshotCache = snapshot?.childTreeSnapshots, contextForChildren = coroutineContext, @@ -87,7 +89,8 @@ internal class WorkflowNode( workflowTracer = workflowTracer, workflowSession = this, interceptor = interceptor, - idCounter = idCounter + idCounter = idCounter, + requestRerender = { eventActionsChannel.trySend(RecomposeAction()) }, ) private val sideEffects = ActiveStagingList() private val remembered = ActiveStagingList>() @@ -325,7 +328,9 @@ internal class WorkflowNode( // Aggregate the action with the child result, if any. val aggregateActionApplied = actionApplied.copy( // Changing state is sticky, we pass it up if it ever changed. - stateChanged = actionApplied.stateChanged || (childResult?.stateChanged ?: false) + stateChanged = action is RecomposeAction || + actionApplied.stateChanged || + (childResult?.stateChanged ?: false) ) // Our state changed or one of our children's state changed. subtreeStateDidChange = aggregateActionApplied.stateChanged From 055504fa9658e4ea3e9b0857c6ca0cbcea24df4a Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Mon, 23 Jun 2025 16:35:11 -0700 Subject: [PATCH 2/2] wip --- .../squareup/workflow1/BaseRenderContext.kt | 49 ++--- .../com/squareup/workflow1/IdCacheable.kt | 7 +- .../squareup/workflow1/StatefulWorkflow.kt | 1 + .../workflow1/compose/ComposeWorkflow.kt | 33 +++ .../com/squareup/workflow1/RenderWorkflow.kt | 1 + .../SimpleLoggingWorkflowInterceptor.kt | 11 - .../squareup/workflow1/WorkflowInterceptor.kt | 61 +++--- .../workflow1/internal/ComposeWorkflowNode.kt | 193 ++++++++++++++++++ .../workflow1/internal/RealRenderContext.kt | 32 ++- .../workflow1/internal/SubtreeManager.kt | 80 ++++---- .../workflow1/internal/WorkflowChildNode.kt | 40 ++-- .../internal/WorkflowComposableNode.kt | 161 --------------- .../workflow1/internal/WorkflowNode.kt | 9 +- 13 files changed, 362 insertions(+), 316 deletions(-) create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposeWorkflowNode.kt delete mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt 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 aa520070d..fd2c61832 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -8,9 +8,7 @@ package com.squareup.workflow1 -import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowAction.Companion.noAction -import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -88,27 +86,6 @@ 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. - // * - // * The `emitOutput` function passed to [content] should be used to trigger [WorkflowAction]s in - // * this workflow via [handler]. Every invocation of `emitOutput` will result [handler]s action - // * being sent to this context's [actionSink]. However, it's important for the composable never to - // * send to [actionSink] directly because we need to ensure that any state writes the composable - // * does invalidate their composables before sending into the [actionSink]. - // */ - // @WorkflowExperimentalApi - // public fun renderComposable( - // key: String = "", - // handler: (ChildOutputT) -> WorkflowAction, - // content: @WorkflowComposable @Composable ( - // emitOutput: (ChildOutputT) -> Unit - // ) -> ChildRenderingT - // ): ChildRenderingT - /** * Ensures [sideEffect] is running with the given [key]. * @@ -232,19 +209,19 @@ public fun key: String = "" ): ChildRenderingT = renderChild(child, Unit, key) { noAction() } -/** - * TODO - */ -@WorkflowExperimentalApi -public fun - BaseRenderContext.renderComposable( - key: String = "", - content: @WorkflowComposable @Composable () -> ChildRenderingT -): ChildRenderingT = renderComposable( - key = key, - handler = { noAction() }, - content = { content() } -) +// /** +// * TODO +// */ +// @WorkflowExperimentalApi +// public fun +// BaseRenderContext.renderComposable( +// key: String = "", +// content: @WorkflowComposable @Composable () -> ChildRenderingT +// ): ChildRenderingT = renderComposable( +// key = key, +// handler = { noAction() }, +// content = { content() } +// ) /** * Ensures a [LifecycleWorker] is running. Since [worker] can't emit anything, diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/IdCacheable.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/IdCacheable.kt index af6d944fb..8a549b873 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/IdCacheable.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/IdCacheable.kt @@ -1,9 +1,12 @@ package com.squareup.workflow1 +import com.squareup.workflow1.compose.ComposeWorkflow + /** * If your Workflow caches its [WorkflowIdentifier] (to avoid frequent lookups) then implement - * this interface. Note that [StatefulWorkflow] and [StatelessWorkflow] already implement this, - * so you only need to do so if you do not extend one of those classes. + * this interface. Note that built-in workflow types ([StatefulWorkflow], [StatelessWorkflow], + * [ComposeWorkflow] etc.) already implement this, so you only need to do so if you do not extend + * one of those classes. * * Your Workflow can just assign null to this value as the [identifier] extension will use it * for caching. diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt index 17965e1a4..6da7720b6 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt @@ -5,6 +5,7 @@ package com.squareup.workflow1 import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS +import com.squareup.workflow1.StatefulWorkflow.RenderContext import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName 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..4e34006c2 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -0,0 +1,33 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.IdCacheable +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi + +/** + * TODO + */ +@WorkflowExperimentalApi +public abstract class ComposeWorkflow : + Workflow, + IdCacheable { + + /** + * TODO + */ + @WorkflowComposable + @Composable + protected abstract fun produceRendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT + + final override fun asStatefulWorkflow(): StatefulWorkflow { + throw UnsupportedOperationException( + "This version of the Compose runtime does not support ComposeWorkflow. " + + "Please upgrade your workflow-runtime." + ) + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 78a25f097..a1997ef49 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -7,6 +7,7 @@ import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete import com.squareup.workflow1.internal.WorkflowRunner import com.squareup.workflow1.internal.chained import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 5e4b74638..58bb7af70 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt @@ -1,6 +1,5 @@ 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 @@ -151,15 +150,5 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { } } } - - override fun onRenderComposable( - key: String, - content: @Composable () -> CR, - proceed: (key: String, content: @Composable () -> CR) -> CR - ): CR = proceed(key) { - logMethod("onRenderComposable", session, "key" to key, "content" to content) { - content() - } - } } } 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 992c033ba..169532b72 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,9 +1,10 @@ +@file:OptIn(WorkflowExperimentalApi::class) + package com.squareup.workflow1 -import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor +import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession -import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext @@ -324,15 +325,6 @@ public interface WorkflowInterceptor { calculation: () -> CResult ) -> CResult ): CResult = proceed(key, resultType, inputs, calculation) - - public fun onRenderComposable( - key: String, - content: @Composable (CO) -> CR, - proceed: ( - key: String, - content: @Composable (CO) -> CR - ) -> CR - ): CR = proceed(key, content) } } @@ -441,6 +433,21 @@ private class InterceptedRenderContext( baseRenderContext.renderChild(iChild, iProps, iKey, iHandler) } + // override fun renderChild( + // child: ComposeWorkflow, + // props: ChildPropsT, + // key: String, + // handler: (ChildOutputT) -> WorkflowAction + // ): ChildRenderingT = + // interceptor.onRenderChild(child, props, key, handler) { iChild, iProps, iKey, iHandler -> + // // Explicitly dispatch to the ComposeWorkflow overload if necessary. + // if (iChild is ComposeWorkflow) { + // baseRenderContext.renderChild(iChild, iProps, iKey, iHandler) + // } else { + // baseRenderContext.renderChild(iChild, iProps, iKey, iHandler) + // } + // } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -470,22 +477,22 @@ private class InterceptedRenderContext( } } - @OptIn(WorkflowExperimentalApi::class) - override fun renderComposable( - key: String, - handler: (ChildOutputT) -> WorkflowAction, - content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT - ): ChildRenderingT = interceptor.onRenderComposable( - key = key, - content = content, - proceed = { iKey, iContent -> - baseRenderContext.renderComposable( - key = iKey, - handler = handler, - content = iContent - ) - } - ) + // @OptIn(WorkflowExperimentalApi::class) + // override fun renderComposable( + // key: String, + // handler: (ChildOutputT) -> WorkflowAction, + // content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + // ): ChildRenderingT = interceptor.onRenderComposable( + // key = key, + // content = content, + // proceed = { iKey, iContent -> + // baseRenderContext.renderComposable( + // key = iKey, + // handler = handler, + // content = iContent + // ) + // } + // ) /** * In a block with a CoroutineScope receiver, calls to `coroutineContext` bind diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposeWorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposeWorkflowNode.kt new file mode 100644 index 000000000..f9d7ba4de --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ComposeWorkflowNode.kt @@ -0,0 +1,193 @@ +package com.squareup.workflow1.internal + +import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.NoopWorkflowInterceptor +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.TreeSnapshot +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.ComposeWorkflow +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume + +@OptIn(WorkflowExperimentalApi::class) +internal class ComposeWorkflowNode( + val id: WorkflowNodeId, + workflow: ComposeWorkflow, + initialProps: PropsT, + snapshot: TreeSnapshot?, + baseContext: CoroutineContext, + // Providing default value so we don't need to specify in test. + override val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + override val workflowTracer: WorkflowTracer? = null, + private val emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult = + { it }, + override val parent: WorkflowSession? = null, + private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, + idCounter: IdCounter? = null +) : CoroutineScope, WorkflowSession { + + /** + * Context that has a job that will live as long as this node. + * Also adds a debug name to this coroutine based on its ID. + */ + override val coroutineContext = baseContext + Job(baseContext[Job]) + CoroutineName(id.toString()) + + /** + * Walk the tree of workflows, rendering each one and using + * [RenderContext][com.squareup.workflow1.BaseRenderContext] to give its children a chance to + * render themselves and aggregate those child renderings. + */ + fun render( + workflow: ComposeWorkflow, + input: PropsT + ): RenderingT = TODO() +} + +// internal class WorkflowComposableNode< +// OutputT, +// RenderingT, +// ParentPropsT, +// ParentStateT, +// ParentOutputT +// >( +// val workflowKey: String, +// private var handler: (OutputT) -> WorkflowAction, +// private val requestRerender: () -> Unit, +// private val sendAction: (WorkflowAction) -> Unit, +// coroutineContext: CoroutineContext = EmptyCoroutineContext, +// ) : InlineListNode>, MonotonicFrameClock { +// +// companion object { +// private fun log(message: String) = message.lines().forEach { +// println("WorkflowComposableNode $it") +// } +// } +// +// override var nextListNode: WorkflowComposableNode<*, *, *, *, *>? = null +// +// private val coroutineContext = coroutineContext + this +// private val recomposer: Recomposer = Recomposer(coroutineContext) +// private val composition: Composition = Composition(UnitApplier, recomposer) +// private val rendering = mutableStateOf(null) +// private var frameRequest: FrameRequest<*>? = null +// private var frameTimeCounter = 0L +// private val emitOutput: (OutputT) -> Unit = { output -> +// // TODO set flag in WFNode saying that it will re-render imminently. +// +// // Allow the function calling this one to finish doing any state updates before triggering +// // a rerender. +// post { +// // Ensure any state updates performed by the caller get to invalidate any compositions that +// // read them. If the dispatcher is Main.immediate, this will synchronously call +// // withFrameNanos, so that needs to check the flag we set above. +// Snapshot.sendApplyNotifications() +// // If dispatcher is Main.immediate this will synchronously perform re-render. +// sendAction(handler(output)) +// } +// } +// +// private fun post(action: () -> Unit) { +// CoroutineScope(coroutineContext).launch { +// val dispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher +// if (dispatcher?.isDispatchNeeded(coroutineContext) != true) { +// // TODO verify this actually posts to the main thread on Main.immediate +// yield() +// } +// action() +// } +// } +// +// 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 { +// log("runRecomposeAndApplyChanges") +// recomposer.runRecomposeAndApplyChanges() +// } finally { +// composition.dispose() +// } +// } +// } +// +// fun dispose() { +// recomposer.cancel() +// } +// +// /** +// * Updates the handler function that will be invoked by [acceptChildOutput]. +// */ +// fun setHandler(newHandler: (CO) -> WorkflowAction) { +// @Suppress("UNCHECKED_CAST") +// handler = newHandler as (OutputT) -> WorkflowAction +// } +// +// /** +// * Has a separate type parameter to allow type erasure. +// */ +// fun render(workflow: ComposeWorkflow<>): R { +// log("render setting content") +// log(RuntimeException().stackTraceToString()) +// composition.setContent { +// @Suppress("UNCHECKED_CAST") +// rendering.value = content(emitOutput as (O) -> Unit) as RenderingT +// } +// +// val frameRequest = this.frameRequest +// if (frameRequest != null) { +// this.frameRequest = null +// val frameTime = frameTimeCounter++ +// log("render executing frame with time $frameTime") +// frameRequest.execute(frameTime) +// log("render finished executing frame with time $frameTime") +// } else { +// log( +// "render no frame request, skipping recomposition " + +// "(hasInvalidations=${composition.hasInvalidations})" +// ) +// } +// +// log("render returning value: ${rendering.value}") +// @Suppress("UNCHECKED_CAST") +// return rendering.value as R +// } +// +// /** +// * Wrapper around [handler] that allows calling it with erased types. +// */ +// @Suppress("UNCHECKED_CAST") +// fun acceptChildOutput(output: Any?): WorkflowAction = +// handler(output as OutputT) +// +// override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { +// check(frameRequest == null) { "Frame already requested" } +// log("withFrameNanos") +// log(RuntimeException().stackTraceToString()) +// return suspendCancellableCoroutine { continuation -> +// frameRequest = FrameRequest( +// onFrame = onFrame, +// continuation = continuation +// ) +// requestRerender() +// } +// } +// } + +private class FrameRequest( + private val onFrame: (frameTimeNanos: Long) -> R, + private val continuation: CancellableContinuation +) { + fun execute(frameTimeNanos: Long) { + val result = onFrame(frameTimeNanos) + continuation.resume(result) + } +} 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 22d13e11b..3330db592 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 @@ -2,7 +2,6 @@ package com.squareup.workflow1.internal -import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.Sink @@ -10,7 +9,6 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowTracer -import com.squareup.workflow1.compose.WorkflowComposable import com.squareup.workflow1.identifier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel @@ -32,12 +30,6 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT - - fun renderComposable( - key: String, - handler: (ChildOutputT) -> WorkflowAction, - content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT - ): ChildRenderingT } interface SideEffectRunner { @@ -89,14 +81,17 @@ internal class RealRenderContext( return renderer.render(child, props, key, handler) } - override fun renderComposable( - key: String, - handler: (ChildOutputT) -> WorkflowAction, - content: @WorkflowComposable @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT - ): ChildRenderingT { - checkNotFrozen() - return renderer.renderComposable(key, handler, content) - } + // override fun renderChild( + // child: ComposeWorkflow, + // props: ChildPropsT, + // key: String, + // handler: (ChildOutputT) -> WorkflowAction + // ): ChildRenderingT { + // checkNotFrozen(child.identifier) { + // "renderChild(${child.identifier})" + // } + // return renderer.render(child, props, key, handler) + // } override fun runningSideEffect( key: String, @@ -137,7 +132,10 @@ internal class RealRenderContext( * * @see checkWithKey */ - private inline fun checkNotFrozen(stackTraceKey: Any, lazyMessage: () -> Any) = + private inline fun checkNotFrozen( + stackTraceKey: Any, + lazyMessage: () -> Any + ) = checkWithKey(!frozen, stackTraceKey) { "RenderContext cannot be used after render method returns: ${lazyMessage()}" } 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 6f2225346..b6934c057 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,6 +1,5 @@ package com.squareup.workflow1.internal -import androidx.compose.runtime.Composable import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor @@ -99,10 +98,8 @@ internal class SubtreeManager( private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, private val idCounter: IdCounter? = null, private val requestRerender: () -> Unit = {}, - private val sendActionFromComposable: (WorkflowAction) -> Unit ) : RealRenderContext.Renderer { private var children = ActiveStagingList>() - private var composables = ActiveStagingList>() /** * Moves all the nodes that have been accumulated in the staging list to the active list, making @@ -116,7 +113,6 @@ internal class SubtreeManager( children.commitStaging { child -> child.workflowNode.cancel() } - composables.commitStaging(onRemove = WorkflowComposableNode<*, *, *, *, *>::dispose) // Get rid of any snapshots that weren't applied on the first render pass. // They belong to children that were saved but not restarted. snapshotCache = null @@ -147,32 +143,32 @@ internal class SubtreeManager( ) } stagedChild.setHandler(handler) - return stagedChild.render(child.asStatefulWorkflow(), props) + return stagedChild.workflowNode.renderErased(child.asStatefulWorkflow(), props) } - override fun renderComposable( - key: String, - handler: (ChildOutputT) -> WorkflowAction, - content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT - ): ChildRenderingT { - // Prevent duplicate workflows with the same key. - workflowTracer.trace("CheckingUniqueMatchesComposable") { - composables.forEachStaging { - require(key != it.workflowKey) { - "Expected keys to be unique for composable: key=\"$key\"" - } - } - } - - val stagedComposable = workflowTracer.trace("RetainingComposables") { - composables.retainOrCreate( - predicate = { it.workflowKey == key }, - create = { createComposableNode(key, handler) } - ) - } - stagedComposable.setHandler(handler) - return stagedComposable.render(content) - } + // override fun renderComposable( + // key: String, + // handler: (ChildOutputT) -> WorkflowAction, + // content: @Composable (emitOutput: (ChildOutputT) -> Unit) -> ChildRenderingT + // ): ChildRenderingT { + // // Prevent duplicate workflows with the same key. + // workflowTracer.trace("CheckingUniqueMatchesComposable") { + // composables.forEachStaging { + // require(key != it.workflowKey) { + // "Expected keys to be unique for composable: key=\"$key\"" + // } + // } + // } + // + // val stagedComposable = workflowTracer.trace("RetainingComposables") { + // composables.retainOrCreate( + // predicate = { it.workflowKey == key }, + // create = { createComposableNode(key, handler) } + // ) + // } + // stagedComposable.setHandler(handler) + // return stagedComposable.render(content) + // } /** * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance @@ -237,18 +233,18 @@ internal class SubtreeManager( .also { node = it } } - private fun createComposableNode( - key: String, - handler: (ChildOutputT) -> WorkflowAction, - ): WorkflowComposableNode { - return WorkflowComposableNode( - workflowKey = key, - handler = handler, - coroutineContext = contextForChildren, - requestRerender = requestRerender, - sendAction = sendActionFromComposable, - ).also { - it.start() - } - } + // private fun createComposableNode( + // key: String, + // handler: (ChildOutputT) -> WorkflowAction, + // ): ComposeWorkflowNode { + // return ComposeWorkflowNode( + // workflowKey = key, + // handler = handler, + // coroutineContext = contextForChildren, + // requestRerender = requestRerender, + // sendAction = sendActionFromComposable, + // ).also { + // it.start() + // } + // } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt index ea2d46876..27fdd6a6a 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt @@ -47,19 +47,19 @@ internal class WorkflowChildNode< newHandler as (ChildOutputT) -> WorkflowAction } - /** - * Wrapper around [WorkflowNode.render] that allows calling it with erased types. - */ - fun render( - workflow: StatefulWorkflow<*, *, *, *>, - props: Any? - ): R { - @Suppress("UNCHECKED_CAST") - return workflowNode.render( - workflow as StatefulWorkflow, - props as ChildPropsT - ) as R - } + // /** + // * Wrapper around [WorkflowNode.render] that allows calling it with erased types. + // */ + // fun render( + // workflow: StatefulWorkflow<*, *, *, *>, + // props: Any? + // ): R { + // @Suppress("UNCHECKED_CAST") + // return workflowNode.render( + // workflow as StatefulWorkflow, + // props as ChildPropsT + // ) as R + // } /** * Wrapper around [handler] that allows calling it with erased types. @@ -68,3 +68,17 @@ internal class WorkflowChildNode< fun acceptChildOutput(output: Any?): WorkflowAction = handler(output as ChildOutputT) } + +/** + * Wrapper around [WorkflowNode.render] that allows calling it with erased types. + */ +internal fun WorkflowNode.renderErased( + workflow: StatefulWorkflow<*, *, *, *>, + props: Any? +): CR { + @Suppress("UNCHECKED_CAST") + return this.render( + workflow as StatefulWorkflow, + props as P + ) as CR +} 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 deleted file mode 100644 index 4bc0d14b2..000000000 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowComposableNode.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.squareup.workflow1.internal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composition -import androidx.compose.runtime.MonotonicFrameClock -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.Snapshot -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.internal.InlineLinkedList.InlineListNode -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.yield -import kotlin.coroutines.ContinuationInterceptor -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.resume - -internal class WorkflowComposableNode< - OutputT, - RenderingT, - ParentPropsT, - ParentStateT, - ParentOutputT - >( - val workflowKey: String, - private var handler: (OutputT) -> WorkflowAction, - private val requestRerender: () -> Unit, - private val sendAction: (WorkflowAction) -> Unit, - coroutineContext: CoroutineContext = EmptyCoroutineContext, -) : InlineListNode>, MonotonicFrameClock { - - companion object { - private fun log(message: String) = message.lines().forEach { - println("WorkflowComposableNode $it") - } - } - - override var nextListNode: WorkflowComposableNode<*, *, *, *, *>? = null - - private val coroutineContext = coroutineContext + this - private val recomposer: Recomposer = Recomposer(coroutineContext) - private val composition: Composition = Composition(UnitApplier, recomposer) - private val rendering = mutableStateOf(null) - private var frameRequest: FrameRequest<*>? = null - private var frameTimeCounter = 0L - private val emitOutput: (OutputT) -> Unit = { output -> - // TODO set flag in WFNode saying that it will re-render imminently. - - // Allow the function calling this one to finish doing any state updates before triggering - // a rerender. - post { - // Ensure any state updates performed by the caller get to invalidate any compositions that - // read them. If the dispatcher is Main.immediate, this will synchronously call - // withFrameNanos, so that needs to check the flag we set above. - Snapshot.sendApplyNotifications() - // If dispatcher is Main.immediate this will synchronously perform re-render. - sendAction(handler(output)) - } - } - - private fun post(action: () -> Unit) { - CoroutineScope(coroutineContext).launch { - val dispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher - if (dispatcher?.isDispatchNeeded(coroutineContext) != true) { - // TODO verify this actually posts to the main thread on Main.immediate - yield() - } - action() - } - } - - 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 { - log("runRecomposeAndApplyChanges") - recomposer.runRecomposeAndApplyChanges() - } finally { - composition.dispose() - } - } - } - - fun dispose() { - recomposer.cancel() - } - - /** - * Updates the handler function that will be invoked by [acceptChildOutput]. - */ - fun setHandler(newHandler: (CO) -> WorkflowAction) { - @Suppress("UNCHECKED_CAST") - handler = newHandler as (OutputT) -> WorkflowAction - } - - /** - * Has a separate type parameter to allow type erasure. - */ - fun render(content: @Composable (emitOutput: (O) -> Unit) -> R): R { - log("render setting content") - log(RuntimeException().stackTraceToString()) - composition.setContent { - @Suppress("UNCHECKED_CAST") - rendering.value = content(emitOutput as (O) -> Unit) as RenderingT - } - - val frameRequest = this.frameRequest - if (frameRequest != null) { - this.frameRequest = null - val frameTime = frameTimeCounter++ - log("render executing frame with time $frameTime") - frameRequest.execute(frameTime) - log("render finished executing frame with time $frameTime") - } else { - log( - "render no frame request, skipping recomposition " + - "(hasInvalidations=${composition.hasInvalidations})" - ) - } - - log("render returning value: ${rendering.value}") - @Suppress("UNCHECKED_CAST") - return rendering.value as R - } - - /** - * Wrapper around [handler] that allows calling it with erased types. - */ - @Suppress("UNCHECKED_CAST") - fun acceptChildOutput(output: Any?): WorkflowAction = - handler(output as OutputT) - - override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { - check(frameRequest == null) { "Frame already requested" } - log("withFrameNanos") - log(RuntimeException().stackTraceToString()) - return suspendCancellableCoroutine { continuation -> - frameRequest = FrameRequest( - onFrame = onFrame, - continuation = continuation - ) - requestRerender() - } - } -} - -private class FrameRequest( - private val onFrame: (frameTimeNanos: Long) -> R, - private val continuation: CancellableContinuation -) { - fun execute(frameTimeNanos: Long) { - val result = onFrame(frameTimeNanos) - continuation.resume(result) - } -} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index 2a2220955..04c680fb2 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -79,8 +79,6 @@ internal class WorkflowNode( private var cachedWorkflowInstance: StatefulWorkflow private var interceptedWorkflowInstance: StatefulWorkflow - private val eventActionsChannel = - Channel>(capacity = UNLIMITED) private val subtreeManager = SubtreeManager( snapshotCache = snapshot?.childTreeSnapshots, contextForChildren = coroutineContext, @@ -89,8 +87,7 @@ internal class WorkflowNode( workflowTracer = workflowTracer, workflowSession = this, interceptor = interceptor, - idCounter = idCounter, - requestRerender = { eventActionsChannel.trySend(RecomposeAction()) }, + idCounter = idCounter ) private val sideEffects = ActiveStagingList() private val remembered = ActiveStagingList>() @@ -328,9 +325,7 @@ internal class WorkflowNode( // Aggregate the action with the child result, if any. val aggregateActionApplied = actionApplied.copy( // Changing state is sticky, we pass it up if it ever changed. - stateChanged = action is RecomposeAction || - actionApplied.stateChanged || - (childResult?.stateChanged ?: false) + stateChanged = actionApplied.stateChanged || (childResult?.stateChanged ?: false) ) // Our state changed or one of our children's state changed. subtreeStateDidChange = aggregateActionApplied.stateChanged