diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt index 83fd5553e9..957c0807bc 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt @@ -3,7 +3,6 @@ package com.squareup.workflow1 -import kotlin.LazyThreadSafetyMode.NONE import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -33,11 +32,55 @@ public abstract class StatelessWorkflow ) : BaseRenderContext<@UnsafeVariance PropsT, Nothing, @UnsafeVariance OutputT> by baseContext as BaseRenderContext - @Suppress("UNCHECKED_CAST") - private val statefulWorkflow = Workflow.stateful( - initialState = { Unit }, - render = { props, _ -> render(props, RenderContext(this, this@StatelessWorkflow)) } - ) + /** + * Class type returned by [asStatefulWorkflow]. + * See [statefulWorkflow] for the instance. + */ + private inner class StatelessAsStatefulWorkflow : + StatefulWorkflow() { + + /** + * We want to cache the render context so that we don't have to recreate it each time + * render() is called. + */ + private var cachedStatelessRenderContext: + StatelessWorkflow.RenderContext? = null + + /** + * We must know if the RenderContext we are passed (which is a StatefulWorkflow.RenderContext) + * has changed, so keep track of it. + */ + private var canonicalStatefulRenderContext: + StatefulWorkflow.RenderContext? = null + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ) = Unit + + override fun render( + renderProps: PropsT, + renderState: Unit, + context: RenderContext + ): RenderingT { + // The `RenderContext` used *might* change - primarily in the case of our tests. E.g., The + // `RenderTester` uses a special NoOp context to render twice to test for idempotency. + // In order to support a changed render context but keep caching, we check to see if the + // instance passed in has changed. + if (cachedStatelessRenderContext == null || context !== canonicalStatefulRenderContext) { + // Recreate it if the StatefulWorkflow.RenderContext we are passed has changed. + cachedStatelessRenderContext = RenderContext(context, this@StatelessWorkflow) + } + canonicalStatefulRenderContext = context + // Pass the StatelessWorkflow.RenderContext to our StatelessWorkflow. + return render(renderProps, cachedStatelessRenderContext!!) + } + + override fun snapshotState(state: Unit): Snapshot? = null + } + + private val statefulWorkflow: StatefulWorkflow = + StatelessAsStatefulWorkflow() /** * Called at least once any time one of the following things happens: @@ -69,9 +112,6 @@ public abstract class StatelessWorkflow /** * Satisfies the [Workflow] interface by wrapping `this` in a [StatefulWorkflow] with `Unit` * state. - * - * This method is called a few times per instance, but we don't need to allocate a new - * [StatefulWorkflow] every time, so we store it in a private property. */ final override fun asStatefulWorkflow(): StatefulWorkflow = statefulWorkflow 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 e86b5901e4..637615650c 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -268,6 +268,9 @@ public object NoopWorkflowInterceptor : WorkflowInterceptor /** * Returns a [StatefulWorkflow] that will intercept all calls to [workflow] via this * [WorkflowInterceptor]. + * + * This is called once for each instance/session of a Workflow being intercepted. So we cache the + * render context for re-use within that [WorkflowSession]. */ @OptIn(WorkflowExperimentalApi::class) internal fun WorkflowInterceptor.intercept( @@ -277,6 +280,22 @@ internal fun WorkflowInterceptor.intercept( workflow } else { object : SessionWorkflow() { + + /** + * Render context that we are passed. + */ + private var canonicalRenderContext: StatefulWorkflow.RenderContext? = null + + /** + * Render context interceptor that we are passed. + */ + private var canonicalRenderContextInterceptor: RenderContextInterceptor? = null + + /** + * Cache of the intercepted render context. + */ + private var cachedInterceptedRenderContext: StatefulWorkflow.RenderContext? = null + override fun initialState( props: P, snapshot: Snapshot?, @@ -298,9 +317,21 @@ internal fun WorkflowInterceptor.intercept( renderState, context, proceed = { props, state, interceptor -> - val interceptedContext = interceptor?.let { InterceptedRenderContext(context, it) } - ?: context - workflow.render(props, state, RenderContext(interceptedContext, this)) + // The `RenderContext` used *might* change - primarily in the case of our tests. E.g., The + // `RenderTester` uses a special NoOp context to render twice to test for idempotency. + // In order to support a changed render context but keep caching, we check to see if the + // instance passed in has changed. + if (cachedInterceptedRenderContext == null || canonicalRenderContext !== context || + canonicalRenderContextInterceptor != interceptor + ) { + val interceptedRenderContext = interceptor?.let { InterceptedRenderContext(context, it) } + ?: context + cachedInterceptedRenderContext = RenderContext(interceptedRenderContext, this) + } + canonicalRenderContext = context + canonicalRenderContextInterceptor = interceptor + // Use the intercepted RenderContext for rendering. + workflow.render(props, state, cachedInterceptedRenderContext!!) }, session = workflowSession, )