From b8591f4093504dbe2fb1a56a7cfb677951cb362f Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 20 Jan 2025 11:02:55 -0500 Subject: [PATCH] 1247: Partial Tree Rerendering Track whether or not the state (or state of a child) has changed in the WorkflowNode. Pass lastRendering if its not. --- .../workflow1/internal/WorkflowNode.kt | 65 +++++++++++++------ .../workflow1/WorkflowOperatorsTest.kt | 20 +++++- 2 files changed, 63 insertions(+), 22 deletions(-) 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 5f71af838..b7ce8df4e 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 @@ -6,11 +6,13 @@ import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RenderContext import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES 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 @@ -44,7 +46,7 @@ import kotlin.coroutines.CoroutineContext * hard-coded values added to worker contexts. It must not contain a [Job] element (it would violate * structured concurrency). */ -@OptIn(WorkflowExperimentalApi::class) +@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) internal class WorkflowNode( val id: WorkflowNodeId, workflow: StatefulWorkflow, @@ -86,9 +88,11 @@ internal class WorkflowNode( ) private val sideEffects = ActiveStagingList() private var lastProps: PropsT = initialProps + private var lastRendering: RenderingT? = null private val eventActionsChannel = Channel>(capacity = UNLIMITED) private var state: StateT + private var subtreeStateDidChange: Boolean = true private val baseRenderContext = RealRenderContext( renderer = subtreeManager, @@ -205,16 +209,21 @@ internal class WorkflowNode( coroutineContext.cancel(cause) } - // 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. + /** 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. + * + * @return true if the instance has changed, otherwise false. + */ private fun updateCachedWorkflowInstance( workflow: StatefulWorkflow - ) { + ): Boolean { if (workflow !== cachedWorkflowInstance) { // instance has changed. interceptedWorkflowInstance = interceptor.intercept(workflow, this) cachedWorkflowInstance = workflow + return true } + return false } /** @@ -225,33 +234,49 @@ internal class WorkflowNode( workflow: StatefulWorkflow, props: PropsT ): RenderingT { - updateCachedWorkflowInstance(workflow) - updatePropsAndState(props) + val didUpdateCachedInstance = updatePropsAndState(props, workflow) - baseRenderContext.unfreeze() - val rendering = interceptedWorkflowInstance.render(props, state, context) - baseRenderContext.freeze() + if (!runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) || + lastRendering == null || + subtreeStateDidChange + ) { + if (!didUpdateCachedInstance) { + // If we haven't already updated the cached instance, better do it now! + updateCachedWorkflowInstance(workflow) + } + baseRenderContext.unfreeze() + lastRendering = 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() } + 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() } + } } - return rendering + return lastRendering!! } + /** + * @return true if the [interceptedWorkflowInstance] has been updated, false otherwise. + */ private fun updatePropsAndState( - newProps: PropsT - ) { + newProps: PropsT, + workflow: StatefulWorkflow, + ): Boolean { + var didUpdateCachedInstance = false if (newProps != lastProps) { + didUpdateCachedInstance = updateCachedWorkflowInstance(workflow) val newState = interceptedWorkflowInstance.onPropsChanged(lastProps, newProps, state) state = newState + subtreeStateDidChange = true } lastProps = newProps + return didUpdateCachedInstance } /** @@ -270,6 +295,8 @@ internal class WorkflowNode( // Changing state is sticky, we pass it up if it ever changed. stateChanged = actionApplied.stateChanged || (childResult?.stateChanged ?: false) ) + // Our state changed or one of our children's state changed. + subtreeStateDidChange = aggregateActionApplied.stateChanged return if (actionApplied.output != null) { emitAppliedActionToParent(aggregateActionApplied) } else { diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt index 735b966f3..1bc5bc0c2 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt @@ -207,23 +207,37 @@ class WorkflowOperatorsTest { private abstract class StateFlowWorkflow( val name: String, val flow: StateFlow - ) : StatelessWorkflow() { + ) : StatefulWorkflow() { var starts: Int = 0 private set + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): T { + return flow.value + } + private val rerenderWorker = object : Worker { override fun run(): Flow = flow.onStart { starts++ } } override fun render( renderProps: Unit, + renderState: T, context: RenderContext ): T { // Listen to the flow to trigger a re-render when it updates. - context.runningWorker(rerenderWorker as Worker) { WorkflowAction.noAction() } - return flow.value + context.runningWorker(rerenderWorker) { output: T -> + action("rerenderUpdate") { + state = output + } + } + return renderState } + override fun snapshotState(state: T): Snapshot? = null + override fun toString(): String = "StateFlowWorkflow($name)" } }