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 324ed39ae..903599871 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1 import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor +import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -194,6 +195,8 @@ public interface WorkflowInterceptor { /** * Information about the session of a workflow in the runtime that a [WorkflowInterceptor] method * is intercepting. + * + * Implementations should override [toString] to call [WorkflowSession.workflowSessionToString]. */ public interface WorkflowSession { /** The [WorkflowIdentifier] that represents the type of this workflow. */ @@ -419,6 +422,16 @@ internal fun WorkflowInterceptor.intercept( } } +internal fun WorkflowSession.workflowSessionToString(): String { + val parentDescription = parent?.let { "WorkflowInstance(…)" } + return "WorkflowInstance(" + + "identifier=$identifier, " + + "renderKey=$renderKey, " + + "instanceId=$sessionId, " + + "parent=$parentDescription" + + ")" +} + private class InterceptedRenderContext( private val baseRenderContext: BaseRenderContext, private val interceptor: RenderContextInterceptor diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNode.kt new file mode 100644 index 000000000..aa09861d0 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNode.kt @@ -0,0 +1,374 @@ +package com.squareup.workflow1.internal + +import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted +import com.squareup.workflow1.NoopWorkflowInterceptor +import com.squareup.workflow1.NullableInitBox +import com.squareup.workflow1.RenderContext +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.WorkflowIdentifier +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.applyTo +import com.squareup.workflow1.intercept +import com.squareup.workflow1.internal.RealRenderContext.RememberStore +import com.squareup.workflow1.internal.RealRenderContext.SideEffectRunner +import com.squareup.workflow1.trace +import com.squareup.workflow1.workflowSessionToString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.selects.SelectBuilder +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KType + +/** + * A node in a state machine tree. Manages the actual state for a given [Workflow]. + * + * @param emitAppliedActionToParent A function that this node will call to pass the result of + * applying an action to its parent. + * @param baseContext [CoroutineContext] that is appended to the end of the context used to launch + * worker coroutines. This context will override anything from the workflow's scope and any other + * hard-coded values added to worker contexts. It must not contain a [Job] element (it would violate + * structured concurrency). + */ +@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) +internal class StatefulWorkflowNode( + id: WorkflowNodeId, + workflow: StatefulWorkflow, + 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, + emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult = { it }, + override val parent: WorkflowSession? = null, + interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, + idCounter: IdCounter? = null +) : WorkflowNode( + id = id, + baseContext = baseContext, + interceptor = interceptor, + emitAppliedActionToParent = emitAppliedActionToParent, +), + SideEffectRunner, + RememberStore, + WorkflowSession { + + // WorkflowSession properties + override val identifier: WorkflowIdentifier get() = id.identifier + override val renderKey: String get() = id.name + override val sessionId: Long = idCounter.createId() + private var cachedWorkflowInstance: StatefulWorkflow + private var interceptedWorkflowInstance: StatefulWorkflow + + override val session: WorkflowSession + get() = this + + private val subtreeManager = SubtreeManager( + snapshotCache = snapshot?.childTreeSnapshots, + contextForChildren = scope.coroutineContext, + emitActionToParent = ::applyAction, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + workflowSession = this, + interceptor = interceptor, + idCounter = idCounter + ) + private val sideEffects = ActiveStagingList() + private val remembered = ActiveStagingList>() + private var lastProps: PropsT = initialProps + private var lastRendering: NullableInitBox = NullableInitBox() + private val eventActionsChannel = + Channel>(capacity = UNLIMITED) + private var state: StateT + + /** + * The state of this node or that of one of our descendants changed since we last rendered. + */ + private var subtreeStateDirty: Boolean = true + + /** + * The state of this node changed since we last rendered. + */ + private var selfStateDirty: Boolean = true + + private val baseRenderContext = RealRenderContext( + renderer = subtreeManager, + sideEffectRunner = this, + rememberStore = this, + eventActionsChannel = eventActionsChannel, + workflowTracer = workflowTracer, + runtimeConfig = runtimeConfig + ) + private val context = RenderContext(baseRenderContext, workflow) + + init { + interceptor.onSessionStarted(workflowScope = scope, session = this) + + cachedWorkflowInstance = workflow + interceptedWorkflowInstance = interceptor.intercept( + workflow = cachedWorkflowInstance, + workflowSession = this + ) + state = interceptedWorkflowInstance.initialState( + props = initialProps, + snapshot = snapshot?.workflowSnapshot, + workflowScope = scope + ) + } + + override fun toString(): String = workflowSessionToString() + + /** + * 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. + */ + @Suppress("UNCHECKED_CAST") + override fun render( + workflow: Workflow, + input: PropsT + ): RenderingT = renderWithStateType( + workflow = workflow.asStatefulWorkflow() as + StatefulWorkflow, + props = input + ) + + /** + * Walk the tree of state machines again, this time gathering snapshots and aggregating them + * automatically. + */ + override fun snapshot(): TreeSnapshot { + return interceptor.onSnapshotStateWithChildren( + proceed = { + val childSnapshots = subtreeManager.createChildSnapshots() + val rootSnapshot = interceptedWorkflowInstance.snapshotState(state) + TreeSnapshot( + workflowSnapshot = rootSnapshot, + // Create the snapshots eagerly since subtreeManager is mutable. + childTreeSnapshots = { childSnapshots } + ) + }, + session = this + ) + } + + override fun runningSideEffect( + key: String, + sideEffect: suspend CoroutineScope.() -> Unit + ) { + // Prevent duplicate side effects with the same key. + sideEffects.forEachStaging { + requireWithKey(key != it.key, key) { "Expected side effect keys to be unique: \"$key\"" } + } + + sideEffects.retainOrCreate( + predicate = { key == it.key }, + create = { createSideEffectNode(key, sideEffect) } + ) + } + + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + remembered.forEachStaging { + requireWithKey( + key != it.key || resultType != it.resultType || !inputs.contentEquals(it.inputs), + stackTraceKey = key + ) { + "Expected unique combination of key, input types and result type: \"$key\"" + } + } + + val result = remembered.retainOrCreate( + predicate = { + key == it.key && it.resultType == resultType && inputs.contentEquals(it.inputs) + }, + create = { RememberedNode(key, resultType, inputs, calculation()) } + ) + + @Suppress("UNCHECKED_CAST") + return result.lastCalculated as ResultT + } + + override fun registerTreeActionSelectors(selector: SelectBuilder) { + // Listen for any child workflow updates. + subtreeManager.registerChildActionSelectors(selector) + + // Listen for any events. + with(selector) { + eventActionsChannel.onReceive { action -> + return@onReceive applyAction(action) + } + } + } + + override fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean): ActionProcessingResult { + if (skipDirtyNodes && selfStateDirty) return ActionsExhausted + + val result = subtreeManager.applyNextAvailableChildAction(skipDirtyNodes) + + if (result == ActionsExhausted) { + return eventActionsChannel.tryReceive().getOrNull()?.let { action -> + applyAction(action) + } ?: ActionsExhausted + } + return result + } + + /** + * Cancels this state machine host, and any coroutines started as children of it. + * + * This must be called when the caller will no longer call [registerTreeActionSelectors]. It is an + * error to call [registerTreeActionSelectors] after calling this method. + */ + override fun cancel(cause: CancellationException?) { + super.cancel(cause) + lastRendering = NullableInitBox() + } + + /** + * Call this after we have been passed any workflow instance, in [render] or [snapshot]. It may + * have changed and we should check to see if we need to update our cached instances. + */ + private fun maybeUpdateCachedWorkflowInstance( + workflow: StatefulWorkflow + ) { + if (workflow !== cachedWorkflowInstance) { + // The instance has changed. + cachedWorkflowInstance = workflow + interceptedWorkflowInstance = interceptor.intercept(cachedWorkflowInstance, this) + } + } + + /** + * Contains the actual logic for [render], after we've casted the passed-in [Workflow]'s + * state type to our `StateT`. + */ + private fun renderWithStateType( + workflow: StatefulWorkflow, + props: PropsT + ): RenderingT { + updatePropsAndState(props, workflow) + + if (!runtimeConfig.contains(PARTIAL_TREE_RENDERING) || + !lastRendering.isInitialized || + subtreeStateDirty + ) { + // If we haven't already updated the cached instance, better do it now! + maybeUpdateCachedWorkflowInstance(workflow) + + baseRenderContext.unfreeze() + lastRendering = NullableInitBox(interceptedWorkflowInstance.render(props, state, context)) + baseRenderContext.freeze() + + workflowTracer.trace("UpdateRuntimeTree") { + // Tear down workflows and workers that are obsolete. + subtreeManager.commitRenderedChildren() + // Side effect jobs are launched lazily, since they can send actions to the sink, and can only + // be started after context is frozen. + sideEffects.forEachStaging { it.job.start() } + sideEffects.commitStaging { it.job.cancel() } + remembered.commitStaging { /* Nothing to clean up. */ } + } + // After we have rendered this subtree, we need another action in order for us to be + // considered dirty again. + subtreeStateDirty = false + selfStateDirty = false + } + + return lastRendering.getOrThrow() + } + + /** + * Update props if they have changed. If that happens, then check to see if we need + * to update the cached workflow instance, then call [StatefulWorkflow.onPropsChanged] and + * update the state from that. We consider any change to props as dirty because + * the props themselves are used in [StatefulWorkflow.render] (they are the 'external' part of + * the state) so we must re-render. + */ + private fun updatePropsAndState( + newProps: PropsT, + workflow: StatefulWorkflow, + ) { + if (newProps != lastProps) { + maybeUpdateCachedWorkflowInstance(workflow) + val newState = interceptedWorkflowInstance.onPropsChanged(lastProps, newProps, state) + state = newState + subtreeStateDirty = true + selfStateDirty = true + } + lastProps = newProps + } + + /** + * Applies [action] to this workflow's [state] and then passes the resulting [ActionApplied] + * via [emitAppliedActionToParent] to the parent, with additional information as to whether or + * not this action has changed the current node's state. + */ + private fun applyAction( + action: WorkflowAction, + childResult: ActionApplied<*>? = null + ): ActionProcessingResult { + val (newState: StateT, actionApplied: ActionApplied) = action.applyTo(lastProps, state) + state = newState + // 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) + ) + // Our state changed. + selfStateDirty = selfStateDirty || actionApplied.stateChanged + // Our state changed or one of our children's state changed. + subtreeStateDirty = subtreeStateDirty || aggregateActionApplied.stateChanged + return if (actionApplied.output != null || + runtimeConfig.contains(PARTIAL_TREE_RENDERING) + ) { + // If we are using the optimization, always return to the parent, so we carry a path that + // notes that the subtree did change all the way to the root. + // + // We don't need that without the optimization because there is nothing + // to output from the root of the runtime -- the output has propagated + // as far as it needs to causing all corresponding state changes. + // + // However, the root and the path down to the changed nodes must always + // re-render now, so this is the implementation detail of how we get + // subtreeStateDirty = true on that entire path to the root. + emitAppliedActionToParent(aggregateActionApplied) + } else { + aggregateActionApplied + } + } + + private fun createSideEffectNode( + key: String, + sideEffect: suspend CoroutineScope.() -> Unit + ): SideEffectNode { + return workflowTracer.trace("CreateSideEffectNode") { + val scope = scope + CoroutineName("sideEffect[$key] for $id") + val job = scope.launch(start = LAZY, block = sideEffect) + SideEffectNode(key, job) + } + } +} 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 7cf7ab163..60d79ccf5 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 @@ -25,9 +25,9 @@ import kotlin.coroutines.CoroutineContext * * ## Rendering * - * This class implements [RealRenderContext.Renderer], and [WorkflowNode] will pass its instance - * of this class to the [RealRenderContext] on each render pass to render children. That means that - * when a workflow renders a child, this class does the actual work. + * This class implements [RealRenderContext.Renderer], and [StatefulWorkflowNode] will pass its + * instance of this class to the [RealRenderContext] on each render pass to render children. That + * means that when a workflow renders a child, this class does the actual work. * * This class keeps two lists: * 1. Active list: All the children from the last render pass that have not yet been rendered in @@ -55,7 +55,7 @@ import kotlin.coroutines.CoroutineContext * active: [bar] * staging: [foo, baz] * ``` - * 4. When the workflow's render method returns, the [WorkflowNode] calls + * 4. When the workflow's render method returns, the [StatefulWorkflowNode] calls * [commitRenderedChildren], which: * 1. Tears down all the children remaining in the active list * ``` @@ -143,7 +143,7 @@ internal class SubtreeManager( ) } stagedChild.setHandler(handler) - return stagedChild.render(child.asStatefulWorkflow(), props) + return stagedChild.render(child, props) } /** @@ -179,8 +179,7 @@ internal class SubtreeManager( fun createChildSnapshots(): Map { val snapshots = mutableMapOf() children.forEachActive { child -> - val childWorkflow = child.workflow.asStatefulWorkflow() - snapshots[child.id] = child.workflowNode.snapshot(childWorkflow) + snapshots[child.id] = child.workflowNode.snapshot() } return snapshots } @@ -205,9 +204,9 @@ internal class SubtreeManager( val childTreeSnapshots = snapshotCache?.get(id) - val workflowNode = WorkflowNode( + val workflowNode = createWorkflowNode( id = id, - workflow = child.asStatefulWorkflow(), + workflow = child, initialProps = initialProps, snapshot = childTreeSnapshots, baseContext = contextForChildren, 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..87648454d 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 @@ -1,6 +1,5 @@ package com.squareup.workflow1.internal -import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowTracer @@ -22,7 +21,7 @@ internal class WorkflowChildNode< >( val workflow: Workflow<*, ChildOutputT, *>, private var handler: (ChildOutputT) -> WorkflowAction, - val workflowNode: WorkflowNode + val workflowNode: WorkflowNode ) : InlineListNode> { override var nextListNode: WorkflowChildNode<*, *, *, *, *>? = null @@ -51,12 +50,12 @@ internal class WorkflowChildNode< * Wrapper around [WorkflowNode.render] that allows calling it with erased types. */ fun render( - workflow: StatefulWorkflow<*, *, *, *>, + workflow: Workflow<*, *, *>, props: Any? ): R { @Suppress("UNCHECKED_CAST") return workflowNode.render( - workflow as StatefulWorkflow, + workflow as Workflow, props as ChildPropsT ) as R } 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 22b2d5d31..125349287 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 @@ -4,240 +4,110 @@ import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.NoopWorkflowInterceptor -import com.squareup.workflow1.NullableInitBox -import com.squareup.workflow1.RenderContext import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions -import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING -import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.WorkflowExperimentalApi -import com.squareup.workflow1.WorkflowExperimentalRuntime -import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer -import com.squareup.workflow1.applyTo -import com.squareup.workflow1.intercept -import com.squareup.workflow1.internal.RealRenderContext.RememberStore -import com.squareup.workflow1.internal.RealRenderContext.SideEffectRunner -import com.squareup.workflow1.trace import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.LAZY import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext -import kotlin.reflect.KType -/** - * A node in a state machine tree. Manages the actual state for a given [Workflow]. - * - * @param emitAppliedActionToParent A function that this node will call to pass the result of - * applying an action to its parent. - * @param baseContext [CoroutineContext] that is appended to the end of the context used to launch - * worker coroutines. This context will override anything from the workflow's scope and any other - * hard-coded values added to worker contexts. It must not contain a [Job] element (it would violate - * structured concurrency). - */ -@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) -internal class WorkflowNode( - val id: WorkflowNodeId, - workflow: StatefulWorkflow, +internal fun createWorkflowNode( + id: WorkflowNodeId, + workflow: Workflow, 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, + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + workflowTracer: WorkflowTracer? = null, + emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult = { it }, + parent: WorkflowSession? = null, + interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, idCounter: IdCounter? = null -) : CoroutineScope, SideEffectRunner, RememberStore, WorkflowSession { +): WorkflowNode = StatefulWorkflowNode( + id = id, + workflow = workflow.asStatefulWorkflow(), + initialProps = initialProps, + snapshot = snapshot, + baseContext = baseContext, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + emitAppliedActionToParent = emitAppliedActionToParent, + parent = parent, + interceptor = interceptor, + idCounter = idCounter, +) + +internal abstract class WorkflowNode( + val id: WorkflowNodeId, + protected val interceptor: WorkflowInterceptor, + protected val emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult, + baseContext: CoroutineContext, +) { /** - * Context that has a job that will live as long as this node. + * Scope that has a job that will live as long as this node and be cancelled when [cancel] is + * called. * Also adds a debug name to this coroutine based on its ID. */ - override val coroutineContext = baseContext + Job(baseContext[Job]) + CoroutineName(id.toString()) - - // WorkflowInstance properties - override val identifier: WorkflowIdentifier get() = id.identifier - override val renderKey: String get() = id.name - override val sessionId: Long = idCounter.createId() - private var cachedWorkflowInstance: StatefulWorkflow - private var interceptedWorkflowInstance: StatefulWorkflow - - private val subtreeManager = SubtreeManager( - snapshotCache = snapshot?.childTreeSnapshots, - contextForChildren = coroutineContext, - emitActionToParent = ::applyAction, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - workflowSession = this, - interceptor = interceptor, - idCounter = idCounter + val scope: CoroutineScope = CoroutineScope( + baseContext + + Job(parent = baseContext[Job]) + + CoroutineName(id.toString()) ) - private val sideEffects = ActiveStagingList() - private val remembered = ActiveStagingList>() - private var lastProps: PropsT = initialProps - private var lastRendering: NullableInitBox = NullableInitBox() - private val eventActionsChannel = - Channel>(capacity = UNLIMITED) - private var state: StateT /** - * The state of this node or that of one of our descendants changed since we last rendered. + * The [WorkflowSession] that represents this node to [WorkflowInterceptor]s. */ - private var subtreeStateDirty: Boolean = true - - /** - * The state of this node changed since we last rendered. - */ - private var selfStateDirty: Boolean = true - - private val baseRenderContext = RealRenderContext( - renderer = subtreeManager, - sideEffectRunner = this, - rememberStore = this, - eventActionsChannel = eventActionsChannel, - workflowTracer = workflowTracer, - runtimeConfig = runtimeConfig - ) - private val context = RenderContext(baseRenderContext, workflow) - - init { - interceptor.onSessionStarted(this, this) - - cachedWorkflowInstance = workflow - interceptedWorkflowInstance = interceptor.intercept(cachedWorkflowInstance, this) - state = interceptedWorkflowInstance.initialState(initialProps, snapshot?.workflowSnapshot, this) - } - - override fun toString(): String { - val parentDescription = parent?.let { "WorkflowInstance(…)" } - return "WorkflowInstance(" + - "identifier=$identifier, " + - "renderKey=$renderKey, " + - "instanceId=$sessionId, " + - "parent=$parentDescription" + - ")" - } + abstract val session: WorkflowSession /** * 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. + * + * @param workflow The "template" workflow instance used in the current render pass. This isn't + * necessarily the same _instance_ every call, but will be the same _type_. */ - @Suppress("UNCHECKED_CAST") - fun render( - workflow: StatefulWorkflow, + abstract fun render( + workflow: Workflow, input: PropsT - ): RenderingT = - renderWithStateType(workflow as StatefulWorkflow, input) + ): RenderingT /** * Walk the tree of state machines again, this time gathering snapshots and aggregating them * automatically. */ - fun snapshot(workflow: StatefulWorkflow<*, *, *, *>): TreeSnapshot { - @Suppress("UNCHECKED_CAST") - val typedWorkflow = workflow as StatefulWorkflow - maybeUpdateCachedWorkflowInstance(typedWorkflow) - return interceptor.onSnapshotStateWithChildren({ - val childSnapshots = subtreeManager.createChildSnapshots() - val rootSnapshot = interceptedWorkflowInstance.snapshotState(state) - TreeSnapshot( - workflowSnapshot = rootSnapshot, - // Create the snapshots eagerly since subtreeManager is mutable. - childTreeSnapshots = { childSnapshots } - ) - }, this) - } - - override fun runningSideEffect( - key: String, - sideEffect: suspend CoroutineScope.() -> Unit - ) { - // Prevent duplicate side effects with the same key. - sideEffects.forEachStaging { - requireWithKey(key != it.key, key) { "Expected side effect keys to be unique: \"$key\"" } - } - - sideEffects.retainOrCreate( - predicate = { key == it.key }, - create = { createSideEffectNode(key, sideEffect) } - ) - } - - override fun remember( - key: String, - resultType: KType, - vararg inputs: Any?, - calculation: () -> ResultT - ): ResultT { - remembered.forEachStaging { - requireWithKey( - key != it.key || resultType != it.resultType || !inputs.contentEquals(it.inputs), - stackTraceKey = key - ) { - "Expected unique combination of key, input types and result type: \"$key\"" - } - } - - val result = remembered.retainOrCreate( - predicate = { - key == it.key && it.resultType == resultType && inputs.contentEquals(it.inputs) - }, - create = { RememberedNode(key, resultType, inputs, calculation()) } - ) - - @Suppress("UNCHECKED_CAST") - return result.lastCalculated as ResultT - } + abstract fun snapshot(): TreeSnapshot /** * Register select clauses for the next [result][ActionProcessingResult] from the state machine. * - * Walk the tree of state machines, asking each one to wait for its next event. If something happen - * that results in an output, that output is returned. Null means something happened that requires - * a re-render, e.g. my state changed or a child state changed. + * Walk the tree of state machines, asking each one to wait for its next event. If something + * happens that results in an output, that output is returned. Null means something happened that + * requires a re-render, e.g. my state changed or a child state changed. * * It is an error to call this method after calling [cancel]. * * Contrast this to [applyNextAvailableTreeAction], which is used to check for an action * that is already available without waiting, and then _immediately_ apply it. - * The select clauses added here also call [applyAction] when one of them is selected. */ - fun registerTreeActionSelectors(selector: SelectBuilder) { - // Listen for any child workflow updates. - subtreeManager.registerChildActionSelectors(selector) - - // Listen for any events. - with(selector) { - eventActionsChannel.onReceive { action -> - return@onReceive applyAction(action) - } - } - } + abstract fun registerTreeActionSelectors(selector: SelectBuilder) /** * Will try to apply any immediately available actions in this action queue or any of our * children's. * * Contrast this to [registerTreeActionSelectors] which will add select clauses that will await - * the next action. That will also end up with [applyAction] being called when the clauses is - * selected. + * the next action. * * @param skipDirtyNodes Whether or not this should skip over any workflow nodes that are already * 'dirty' - that is, they had their own state changed as the result of a previous action before @@ -246,152 +116,15 @@ internal class WorkflowNode( * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were * none immediately available. */ - fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean = false): ActionProcessingResult { - if (skipDirtyNodes && selfStateDirty) return ActionsExhausted - - val result = subtreeManager.applyNextAvailableChildAction(skipDirtyNodes) - - if (result == ActionsExhausted) { - return eventActionsChannel.tryReceive().getOrNull()?.let { action -> - applyAction(action) - } ?: ActionsExhausted - } - return result - } + abstract fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean = false): ActionProcessingResult /** * Cancels this state machine host, and any coroutines started as children of it. * - * This must be called when the caller will no longer call [registerTreeActionSelectors]. It is an error to call [registerTreeActionSelectors] - * after calling this method. - */ - fun cancel(cause: CancellationException? = null) { - coroutineContext.cancel(cause) - lastRendering = NullableInitBox() - } - - /** - * Call this after we have been passed any workflow instance, in [render] or [snapshot]. It may - * have changed and we should check to see if we need to update our cached instances. - */ - private fun maybeUpdateCachedWorkflowInstance( - workflow: StatefulWorkflow - ) { - if (workflow !== cachedWorkflowInstance) { - // The instance has changed. - cachedWorkflowInstance = workflow - interceptedWorkflowInstance = interceptor.intercept(cachedWorkflowInstance, this) - } - } - - /** - * Contains the actual logic for [render], after we've casted the passed-in [Workflow]'s - * state type to our `StateT`. - */ - private fun renderWithStateType( - workflow: StatefulWorkflow, - props: PropsT - ): RenderingT { - updatePropsAndState(props, workflow) - - if (!runtimeConfig.contains(PARTIAL_TREE_RENDERING) || - !lastRendering.isInitialized || - subtreeStateDirty - ) { - // If we haven't already updated the cached instance, better do it now! - maybeUpdateCachedWorkflowInstance(workflow) - - baseRenderContext.unfreeze() - lastRendering = NullableInitBox(interceptedWorkflowInstance.render(props, state, context)) - baseRenderContext.freeze() - - workflowTracer.trace("UpdateRuntimeTree") { - // Tear down workflows and workers that are obsolete. - subtreeManager.commitRenderedChildren() - // Side effect jobs are launched lazily, since they can send actions to the sink, and can only - // be started after context is frozen. - sideEffects.forEachStaging { it.job.start() } - sideEffects.commitStaging { it.job.cancel() } - remembered.commitStaging { /* Nothing to clean up. */ } - } - // After we have rendered this subtree, we need another action in order for us to be - // considered dirty again. - subtreeStateDirty = false - selfStateDirty = false - } - - return lastRendering.getOrThrow() - } - - /** - * Update props if they have changed. If that happens, then check to see if we need - * to update the cached workflow instance, then call [StatefulWorkflow.onPropsChanged] and - * update the state from that. We consider any change to props as dirty because - * the props themselves are used in [StatefulWorkflow.render] (they are the 'external' part of - * the state) so we must re-render. - */ - private fun updatePropsAndState( - newProps: PropsT, - workflow: StatefulWorkflow, - ) { - if (newProps != lastProps) { - maybeUpdateCachedWorkflowInstance(workflow) - val newState = interceptedWorkflowInstance.onPropsChanged(lastProps, newProps, state) - state = newState - subtreeStateDirty = true - selfStateDirty = true - } - lastProps = newProps - } - - /** - * Applies [action] to this workflow's [state] and then passes the resulting [ActionApplied] - * via [emitAppliedActionToParent] to the parent, with additional information as to whether or - * not this action has changed the current node's state. - * + * This must be called when the caller will no longer call [registerTreeActionSelectors]. It is an + * error to call [registerTreeActionSelectors] after calling this method. */ - private fun applyAction( - action: WorkflowAction, - childResult: ActionApplied<*>? = null - ): ActionProcessingResult { - val (newState: StateT, actionApplied: ActionApplied) = action.applyTo(lastProps, state) - state = newState - // 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) - ) - // Our state changed. - selfStateDirty = selfStateDirty || actionApplied.stateChanged - // Our state changed or one of our children's state changed. - subtreeStateDirty = subtreeStateDirty || aggregateActionApplied.stateChanged - return if (actionApplied.output != null || - runtimeConfig.contains(PARTIAL_TREE_RENDERING) - ) { - // If we are using the optimization, always return to the parent, so we carry a path that - // notes that the subtree did change all the way to the root. - // - // We don't need that without the optimization because there is nothing - // to output from the root of the runtime -- the output has propagated - // as far as it needs to causing all corresponding state changes. - // - // However, the root and the path down to the changed nodes must always - // re-render now, so this is the implementation detail of how we get - // subtreeStateDirty = true on that entire path to the root. - emitAppliedActionToParent(aggregateActionApplied) - } else { - aggregateActionApplied - } - } - - private fun createSideEffectNode( - key: String, - sideEffect: suspend CoroutineScope.() -> Unit - ): SideEffectNode { - return workflowTracer.trace("CreateSideEffectNode") { - val scope = this + CoroutineName("sideEffect[$key] for $id") - val job = scope.launch(start = LAZY, block = sideEffect) - SideEffectNode(key, job) - } + open fun cancel(cause: CancellationException? = null) { + scope.cancel(cause) } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 25e9b15f4..7761aacb3 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -47,7 +47,7 @@ internal class WorkflowRunner( private val propsChannel = props.dropWhile { it == currentProps } .produceIn(scope) - private val rootNode = WorkflowNode( + private val rootNode = createWorkflowNode( id = workflow.id(), workflow = workflow, initialProps = currentProps, @@ -66,11 +66,15 @@ internal class WorkflowRunner( * between every subsequent call to [awaitAndApplyAction]. */ fun nextRendering(): RenderingAndSnapshot { - return interceptor.onRenderAndSnapshot(currentProps, { props -> - val rendering = rootNode.render(workflow, props) - val snapshot = rootNode.snapshot(workflow) - RenderingAndSnapshot(rendering, snapshot) - }, rootNode) + return interceptor.onRenderAndSnapshot( + renderProps = currentProps, + proceed = { props -> + val rendering = rootNode.render(workflow, props) + val snapshot = rootNode.snapshot() + RenderingAndSnapshot(rendering, snapshot) + }, + session = rootNode.session, + ) } /** diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNodeTest.kt similarity index 94% rename from workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt rename to workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNodeTest.kt index 5658d59ee..574e4b902 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/StatefulWorkflowNodeTest.kt @@ -55,7 +55,7 @@ import kotlin.test.assertTrue import kotlin.test.fail @Suppress("UNCHECKED_CAST") -internal class WorkflowNodeTest { +internal class StatefulWorkflowNodeTest { abstract class StringWorkflow : StatefulWorkflow() { override fun snapshotState(state: String): Snapshot = fail("not expected") @@ -108,7 +108,7 @@ internal class WorkflowNodeTest { oldAndNewProps += old to new return@PropsRenderingWorkflow state } - val node = WorkflowNode(workflow.id(), workflow, "old", null, context) + val node = StatefulWorkflowNode(workflow.id(), workflow, "old", null, context) node.render(workflow, "new") @@ -121,7 +121,7 @@ internal class WorkflowNodeTest { oldAndNewProps += old to new return@PropsRenderingWorkflow state } - val node = WorkflowNode(workflow.id(), workflow, "old", null, context) + val node = StatefulWorkflowNode(workflow.id(), workflow, "old", null, context) node.render(workflow, "old") @@ -132,7 +132,7 @@ internal class WorkflowNodeTest { val workflow = PropsRenderingWorkflow { old, new, _ -> "$old->$new" } - val node = WorkflowNode(workflow.id(), workflow, "foo", null, context) + val node = StatefulWorkflowNode(workflow.id(), workflow, "foo", null, context) val rendering = node.render(workflow, "foo2") @@ -173,7 +173,7 @@ internal class WorkflowNodeTest { return context.eventHandler("") { event -> setOutput(event) } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow, "", @@ -215,7 +215,7 @@ internal class WorkflowNodeTest { return context.eventHandler("") { event -> setOutput(event) } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow, "", @@ -267,7 +267,7 @@ internal class WorkflowNodeTest { return "" } } - val node = WorkflowNode(workflow.id(), workflow, "", null, context) + val node = StatefulWorkflowNode(workflow.id(), workflow, "", null, context) node.render(workflow, "") sink.send(action("") { setOutput("event") }) @@ -284,7 +284,7 @@ internal class WorkflowNodeTest { } assertFalse(started) } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -305,7 +305,7 @@ internal class WorkflowNodeTest { contextFromWorker = coroutineContext } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -314,7 +314,10 @@ internal class WorkflowNodeTest { ) node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals(WorkflowNodeId(workflow).toString(), node.coroutineContext[CoroutineName]!!.name) + assertEquals( + WorkflowNodeId(workflow).toString(), + node.scope.coroutineContext[CoroutineName]!!.name + ) assertEquals( "sideEffect[the key] for ${WorkflowNodeId(workflow)}", contextFromWorker!![CoroutineName]!!.name @@ -327,7 +330,7 @@ internal class WorkflowNodeTest { actionSink.send(action("") { setOutput("result") }) } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -359,7 +362,7 @@ internal class WorkflowNodeTest { } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = true, @@ -388,7 +391,7 @@ internal class WorkflowNodeTest { } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -417,7 +420,7 @@ internal class WorkflowNodeTest { } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, @@ -445,7 +448,7 @@ internal class WorkflowNodeTest { seenProps += props } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, @@ -469,7 +472,7 @@ internal class WorkflowNodeTest { runningSideEffect("same") { fail() } runningSideEffect("same") { fail() } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -500,7 +503,7 @@ internal class WorkflowNodeTest { if (props == 2) runningSideEffect("three", recordingSideEffect(events3)) } .asStatefulWorkflow() - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow, initialProps = 0, @@ -536,7 +539,7 @@ internal class WorkflowNodeTest { runningSideEffect("one") { started1 = true } runningSideEffect("two") { started2 = true } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -566,7 +569,7 @@ internal class WorkflowNodeTest { } } ) - val originalNode = WorkflowNode( + val originalNode = StatefulWorkflowNode( workflow.id(), workflow, initialProps = "initial props", @@ -575,10 +578,10 @@ internal class WorkflowNodeTest { ) assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) + val snapshot = originalNode.snapshot() assertNotEquals(0, snapshot.toByteString().size) - val restoredNode = WorkflowNode( + val restoredNode = StatefulWorkflowNode( workflow.id(), workflow, // These props should be ignored, since snapshot is non-null. @@ -595,7 +598,7 @@ internal class WorkflowNodeTest { render = { _, state -> state }, snapshot = { Snapshot.of("restored") } ) - val originalNode = WorkflowNode( + val originalNode = StatefulWorkflowNode( workflow.id(), workflow, initialProps = "initial props", @@ -604,10 +607,10 @@ internal class WorkflowNodeTest { ) assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) + val snapshot = originalNode.snapshot() assertNotEquals(0, snapshot.toByteString().size) - val restoredNode = WorkflowNode( + val restoredNode = StatefulWorkflowNode( workflow.id(), workflow, // These props should be ignored, since snapshot is non-null. @@ -652,7 +655,7 @@ internal class WorkflowNodeTest { } ) - val originalNode = WorkflowNode( + val originalNode = StatefulWorkflowNode( parentWorkflow.id(), parentWorkflow, initialProps = "initial props", @@ -661,10 +664,10 @@ internal class WorkflowNodeTest { ) assertEquals("initial props|child props", originalNode.render(parentWorkflow, "foo")) - val snapshot = originalNode.snapshot(parentWorkflow) + val snapshot = originalNode.snapshot() assertNotEquals(0, snapshot.toByteString().size) - val restoredNode = WorkflowNode( + val restoredNode = StatefulWorkflowNode( parentWorkflow.id(), parentWorkflow, // These props should be ignored, since snapshot is non-null. @@ -695,13 +698,13 @@ internal class WorkflowNodeTest { } } ) - val node = WorkflowNode(workflow.id(), workflow, Unit, null, Unconfined) + val node = StatefulWorkflowNode(workflow.id(), workflow, Unit, null, Unconfined) assertEquals(0, snapshotCalls) assertEquals(0, snapshotWrites) assertEquals(0, restoreCalls) - val snapshot = node.snapshot(workflow) + val snapshot = node.snapshot() assertEquals(1, snapshotCalls) assertEquals(0, snapshotWrites) @@ -713,7 +716,7 @@ internal class WorkflowNodeTest { assertEquals(1, snapshotWrites) assertEquals(0, restoreCalls) - WorkflowNode(workflow.id(), workflow, Unit, snapshot, Unconfined) + StatefulWorkflowNode(workflow.id(), workflow, Unit, snapshot, Unconfined) assertEquals(1, snapshotCalls) assertEquals(1, snapshotWrites) @@ -732,7 +735,7 @@ internal class WorkflowNodeTest { render = { _, state -> state }, snapshot = { state -> Snapshot.write { it.writeUtf8WithLength(state) } } ) - val originalNode = WorkflowNode( + val originalNode = StatefulWorkflowNode( workflow.id(), workflow, initialProps = "initial props", @@ -741,10 +744,10 @@ internal class WorkflowNodeTest { ) assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) + val snapshot = originalNode.snapshot() assertNotEquals(0, snapshot.toByteString().size) - val restoredNode = WorkflowNode( + val restoredNode = StatefulWorkflowNode( workflow.id(), workflow, initialProps = "new props", @@ -756,7 +759,7 @@ internal class WorkflowNodeTest { @Test fun toString_formats_as_WorkflowInstance_without_parent() { val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = Unit, @@ -774,7 +777,7 @@ internal class WorkflowNodeTest { @Test fun toString_formats_as_WorkflowInstance_with_parent() { val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = Unit, @@ -807,7 +810,7 @@ internal class WorkflowNodeTest { } } val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = Unit, @@ -817,7 +820,7 @@ internal class WorkflowNodeTest { parent = TestSession(42) ) - assertSame(node.coroutineContext, interceptedScope.coroutineContext) + assertSame(node.scope.coroutineContext, interceptedScope.coroutineContext) assertEquals(workflow.identifier, interceptedSession.identifier) assertEquals(0, interceptedSession.sessionId) assertEquals("foo", interceptedSession.renderKey) @@ -848,7 +851,7 @@ internal class WorkflowNodeTest { } } val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = Unit, @@ -859,7 +862,7 @@ internal class WorkflowNodeTest { parent = TestSession(42) ) - assertSame(node.coroutineContext, interceptedScope.coroutineContext) + assertSame(node.scope.coroutineContext, interceptedScope.coroutineContext) assertEquals(workflow.identifier, interceptedSession.identifier) assertEquals(0, interceptedSession.sessionId) assertEquals("foo", interceptedSession.renderKey) @@ -895,7 +898,7 @@ internal class WorkflowNodeTest { initialState = { props -> "state($props)" }, render = { _, _ -> fail() } ) - WorkflowNode( + StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = "props", @@ -941,7 +944,7 @@ internal class WorkflowNodeTest { onPropsChanged = { old, new, state -> "onPropsChanged($old, $new, $state)" }, render = { _, state -> state } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = "old", @@ -987,7 +990,7 @@ internal class WorkflowNodeTest { initialState = { "state" }, render = { props, state -> "render($props, $state)" } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = "props", @@ -1029,7 +1032,7 @@ internal class WorkflowNodeTest { render = { _, state -> state }, snapshot = { state -> Snapshot.of("snapshot($state)") } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = "old", @@ -1038,7 +1041,7 @@ internal class WorkflowNodeTest { baseContext = Unconfined, parent = TestSession(42) ) - val snapshot = node.snapshot(workflow) + val snapshot = node.snapshot() assertEquals("state", interceptedState) assertEquals(Snapshot.of("snapshot(state)"), interceptedSnapshot) @@ -1070,7 +1073,7 @@ internal class WorkflowNodeTest { render = { _, state -> state }, snapshot = { null } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = workflow.id(key = "foo"), workflow = workflow.asStatefulWorkflow(), initialProps = "old", @@ -1079,7 +1082,7 @@ internal class WorkflowNodeTest { baseContext = Unconfined, parent = TestSession(42) ) - val snapshot = node.snapshot(workflow) + val snapshot = node.snapshot() assertEquals("state", interceptedState) assertNull(interceptedSnapshot) @@ -1111,7 +1114,7 @@ internal class WorkflowNodeTest { "root(${renderChild(leafWorkflow, props)})" } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( id = rootWorkflow.id(key = "foo"), workflow = rootWorkflow.asStatefulWorkflow(), initialProps = "props", @@ -1131,7 +1134,7 @@ internal class WorkflowNodeTest { val sink = eventHandler("eventHandler") { fail("Expected handler to fail.") } sink() } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1159,7 +1162,7 @@ internal class WorkflowNodeTest { val workflow = Workflow.stateless { actionSink.send(TestAction()) } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1186,7 +1189,7 @@ internal class WorkflowNodeTest { } } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1211,7 +1214,7 @@ internal class WorkflowNodeTest { val workflow = Workflow.stateless> { actionSink.contraMap { action("") { setOutput(it) } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1238,7 +1241,7 @@ internal class WorkflowNodeTest { val workflow = Workflow.stateless> { actionSink.contraMap { action("") { setOutput(null) } } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1269,7 +1272,7 @@ internal class WorkflowNodeTest { return@stateful renderState } ) - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1292,7 +1295,7 @@ internal class WorkflowNodeTest { actionSink.send(action("") { setOutput("child:hello") }) } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1318,7 +1321,7 @@ internal class WorkflowNodeTest { actionSink.send(action("") { setOutput(null) }) } } - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, @@ -1343,7 +1346,7 @@ internal class WorkflowNodeTest { } val stateful = workflow.asStatefulWorkflow() - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), stateful, initialProps = "", @@ -1367,7 +1370,7 @@ internal class WorkflowNodeTest { remember(key, typeOf(), input) { value } } val stateful = workflow.asStatefulWorkflow() - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), stateful, initialProps = "", @@ -1393,7 +1396,7 @@ internal class WorkflowNodeTest { remember(key, returnType) { value } } val stateful = workflow.asStatefulWorkflow() - val node = WorkflowNode( + val node = StatefulWorkflowNode( workflow.id(), stateful, initialProps = "" to ("" as Any),