Skip to content

Commit 1c12e26

Browse files
1249: Cache Interceptor Instance; Do not pass Workflow to render()
1 parent 0bcb692 commit 1c12e26

File tree

8 files changed

+180
-114
lines changed

8 files changed

+180
-114
lines changed

workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Workflow.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,32 @@ public interface Workflow<in PropsT, out OutputT, out RenderingT> {
113113

114114
/**
115115
* Uses the given [function][transform] to transform a [Workflow] that
116-
* renders [FromRenderingT] to one renders [ToRenderingT],
116+
* renders [FromRenderingT] to one renders [ToRenderingT].
117+
*
118+
* Note that since this uses the [identifier] of the Workflow being mapped, this can only be used
119+
* *once* in a given Workflow unless a different key is passed for the subsequent [renderChild]
120+
* call.
121+
*
122+
* In other words, if you would like to use this for multiple children workflows, you *must* render
123+
* them each with separate keys. e.g.:
124+
* ```
125+
* when (props) {
126+
* 0 -> renderChild(childWorkflow.mapRendering { "rendering1: $it" })
127+
* 1 -> renderChild(childWorkflow.mapRendering { "rendering2: $it" }, "rendering2")
128+
* else -> fail()
129+
* }
130+
* ```
131+
* If you do not, the first workflow instance returned by [mapRendering] will simply be used for
132+
* all subsequent.
133+
*
134+
* If this is cumbersome or inconvenient, I would recommend simply calling [transform] directly
135+
* on the rendering itself if it is cheap and idempotent. If it is not, well then, you should
136+
* likely just create 2 different concrete workflow types with the same interface, base class,
137+
* or some other form of re-use.
117138
*/
118139
public fun <PropsT, OutputT, FromRenderingT, ToRenderingT>
119140
Workflow<PropsT, OutputT, FromRenderingT>.mapRendering(
120-
transform: (FromRenderingT) -> ToRenderingT
141+
transform: (FromRenderingT) -> ToRenderingT,
121142
): Workflow<PropsT, OutputT, ToRenderingT> =
122143
object : StatelessWorkflow<PropsT, OutputT, ToRenderingT>(), ImpostorWorkflow {
123144
override val realIdentifier: WorkflowIdentifier get() = this@mapRendering.identifier

workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowIdentifier.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ public class WorkflowIdentifier internal constructor(
129129
}
130130

131131
/**
132-
* Used to detect when this [WorkflowIdentifier] is a deeply stubbed mock. Stubs are provided
133-
* until a primitive, in this case the typeName. This lets us determine if we are a mock
134-
* object, or a real one. Mea culpa.
132+
* Used to detect when this [WorkflowIdentifier] is a deeply stubbed mock. When mocking, object
133+
* Stubs are provided until a primitive is hit, in this case the typeName (a [String]).
134+
* This lets us determine if we are a mock object, or a real one. Mea culpa.
135135
*/
136136
internal val deepNameCheck = type.typeName
137137

@@ -177,11 +177,11 @@ public val Workflow<*, *, *>.identifier: WorkflowIdentifier
177177
is IdCacheable -> {
178178
// The following lines look more complex than they need to be. If we have not yet cached
179179
// the identifier, we do. But we return the [computedIdentifier] value directly as in the
180-
// case of tests which use mocks we want to ensure that we return what is on line 180 -
180+
// case of tests which use mocks we want to ensure that we return what is on line 203 -
181181
// "WorkflowIdentifier(Snapshottable(this::class))" as that depends solely on types.
182-
// We do the 'senseless' comparison of .type.typeName here to detect the case where we
182+
// We do the 'senseless' comparison of .deepNameCheck here to detect the case where we
183183
// we have mocks with deep stubs but the name itself (a String) is null.
184-
// The reason this is so complicated is this caching has been added afterword via the
184+
// The reason this is so complicated is this caching has been added afterwards via the
185185
// [IdCacheable] interface so that the [Workflow] interface itself remains unchanged.
186186
@Suppress("SENSELESS_COMPARISON")
187187
if (cachedIdentifier == null || cachedIdentifier!!.deepNameCheck == null) {

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
141141
)
142142
}
143143
stagedChild.setHandler(handler)
144-
return stagedChild.render(child.asStatefulWorkflow(), props)
144+
return stagedChild.render(props)
145145
}
146146

147147
/**
@@ -163,8 +163,7 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
163163
fun createChildSnapshots(): Map<WorkflowNodeId, TreeSnapshot> {
164164
val snapshots = mutableMapOf<WorkflowNodeId, TreeSnapshot>()
165165
children.forEachActive { child ->
166-
val childWorkflow = child.workflow.asStatefulWorkflow()
167-
snapshots[child.id] = child.workflowNode.snapshot(childWorkflow)
166+
snapshots[child.id] = child.workflowNode.snapshot()
168167
}
169168
return snapshots
170169
}

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.squareup.workflow1.internal
22

3-
import com.squareup.workflow1.StatefulWorkflow
43
import com.squareup.workflow1.Workflow
54
import com.squareup.workflow1.WorkflowAction
65
import com.squareup.workflow1.WorkflowTracer
@@ -51,14 +50,10 @@ internal class WorkflowChildNode<
5150
* Wrapper around [WorkflowNode.render] that allows calling it with erased types.
5251
*/
5352
fun <R> render(
54-
workflow: StatefulWorkflow<*, *, *, *>,
5553
props: Any?
5654
): R {
5755
@Suppress("UNCHECKED_CAST")
58-
return workflowNode.render(
59-
workflow as StatefulWorkflow<ChildPropsT, out Any?, ChildOutputT, Nothing>,
60-
props as ChildPropsT
61-
) as R
56+
return workflowNode.render(props as ChildPropsT) as R
6257
}
6358

6459
/**

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
7171
override val identifier: WorkflowIdentifier get() = id.identifier
7272
override val renderKey: String get() = id.name
7373
override val sessionId: Long = idCounter.createId()
74+
private val interceptedWorkflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>
7475

7576
private val subtreeManager = SubtreeManager(
7677
snapshotCache = snapshot?.childTreeSnapshots,
@@ -99,8 +100,8 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
99100
init {
100101
interceptor.onSessionStarted(this, this)
101102

102-
state = interceptor.intercept(workflow, this)
103-
.initialState(initialProps, snapshot?.workflowSnapshot, this)
103+
interceptedWorkflow = interceptor.intercept(workflow, this)
104+
state = interceptedWorkflow.initialState(initialProps, snapshot?.workflowSnapshot, this)
104105
}
105106

106107
override fun toString(): String {
@@ -118,24 +119,35 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
118119
* [RenderContext][com.squareup.workflow1.BaseRenderContext] to give its children a chance to
119120
* render themselves and aggregate those child renderings.
120121
*/
121-
@Suppress("UNCHECKED_CAST")
122122
fun render(
123-
workflow: StatefulWorkflow<PropsT, *, OutputT, RenderingT>,
124-
input: PropsT
125-
): RenderingT =
126-
renderWithStateType(workflow as StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>, input)
123+
props: PropsT
124+
): RenderingT {
125+
updatePropsAndState(props)
126+
127+
baseRenderContext.unfreeze()
128+
val rendering = interceptedWorkflow.render(props, state, context)
129+
baseRenderContext.freeze()
130+
131+
workflowTracer.trace("UpdateRuntimeTree") {
132+
// Tear down workflows and workers that are obsolete.
133+
subtreeManager.commitRenderedChildren()
134+
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
135+
// be started after context is frozen.
136+
sideEffects.forEachStaging { it.job.start() }
137+
sideEffects.commitStaging { it.job.cancel() }
138+
}
139+
140+
return rendering
141+
}
127142

128143
/**
129144
* Walk the tree of state machines again, this time gathering snapshots and aggregating them
130145
* automatically.
131146
*/
132-
fun snapshot(workflow: StatefulWorkflow<*, *, *, *>): TreeSnapshot {
133-
@Suppress("UNCHECKED_CAST")
134-
val typedWorkflow = workflow as StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>
147+
fun snapshot(): TreeSnapshot {
135148
return interceptor.onSnapshotStateWithChildren({
136149
val childSnapshots = subtreeManager.createChildSnapshots()
137-
val rootSnapshot = interceptor.intercept(typedWorkflow, this)
138-
.snapshotState(state)
150+
val rootSnapshot = interceptedWorkflow.snapshotState(state)
139151
TreeSnapshot(
140152
workflowSnapshot = rootSnapshot,
141153
// Create the snapshots eagerly since subtreeManager is mutable.
@@ -202,40 +214,11 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
202214
coroutineContext.cancel(cause)
203215
}
204216

205-
/**
206-
* Contains the actual logic for [render], after we've casted the passed-in [Workflow]'s
207-
* state type to our `StateT`.
208-
*/
209-
private fun renderWithStateType(
210-
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>,
211-
props: PropsT
212-
): RenderingT {
213-
updatePropsAndState(workflow, props)
214-
215-
baseRenderContext.unfreeze()
216-
val rendering = interceptor.intercept(workflow, this)
217-
.render(props, state, context)
218-
baseRenderContext.freeze()
219-
220-
workflowTracer.trace("UpdateRuntimeTree") {
221-
// Tear down workflows and workers that are obsolete.
222-
subtreeManager.commitRenderedChildren()
223-
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
224-
// be started after context is frozen.
225-
sideEffects.forEachStaging { it.job.start() }
226-
sideEffects.commitStaging { it.job.cancel() }
227-
}
228-
229-
return rendering
230-
}
231-
232217
private fun updatePropsAndState(
233-
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>,
234218
newProps: PropsT
235219
) {
236220
if (newProps != lastProps) {
237-
val newState = interceptor.intercept(workflow, this)
238-
.onPropsChanged(lastProps, newProps, state)
221+
val newState = interceptedWorkflow.onPropsChanged(lastProps, newProps, state)
239222
state = newState
240223
}
241224
lastProps = newProps

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ internal class WorkflowRunner<PropsT, OutputT, RenderingT>(
7070
*/
7171
fun nextRendering(): RenderingAndSnapshot<RenderingT> {
7272
return interceptor.onRenderAndSnapshot(currentProps, { props ->
73-
val rendering = rootNode.render(workflow, props)
74-
val snapshot = rootNode.snapshot(workflow)
73+
val rendering = rootNode.render(props)
74+
val snapshot = rootNode.snapshot()
7575
RenderingAndSnapshot(rendering, snapshot)
7676
}, rootNode)
7777
}

workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class WorkflowOperatorsTest {
151151
}
152152

153153
@Test
154-
fun mapRendering_with_same_upstream_workflow_in_two_different_passes_does_not_restart() {
154+
fun mapRendering_with_same_upstream_workflow_in_two_different_passes_uses_same_instance() {
155155
val trigger = MutableStateFlow("initial")
156156
val childWorkflow = object : StateFlowWorkflow<String>("child", trigger) {}
157157
val parentWorkflow = Workflow.stateless<Int, Nothing, String> { props ->
@@ -194,8 +194,76 @@ class WorkflowOperatorsTest {
194194
listOf(
195195
"rendering1: initial",
196196
"rendering1: foo",
197+
"rendering1: foo",
198+
"rendering1: bar"
199+
),
200+
renderings
201+
)
202+
203+
workflowJob.cancel()
204+
}
205+
}
206+
207+
@Test
208+
fun mapRendering_with_same_upstream_workflow_and_diff_keys_uses_different_instance() {
209+
val trigger = MutableStateFlow("initial")
210+
val childWorkflow = object : StateFlowWorkflow<String>("child", trigger) {}
211+
val parentWorkflow = Workflow.stateless<Int, Nothing, String> { props ->
212+
when (props) {
213+
0 -> renderChild(childWorkflow.mapRendering { "rendering1: $it" })
214+
1 -> renderChild(childWorkflow.mapRendering { "rendering2: $it" }, "rendering2")
215+
else -> fail()
216+
}
217+
}
218+
val props = MutableStateFlow(0)
219+
220+
runTest(UnconfinedTestDispatcher()) {
221+
val renderings = mutableListOf<String>()
222+
val workflowJob = Job(coroutineContext[Job])
223+
renderWorkflowIn(parentWorkflow, this + workflowJob, props) {}
224+
.onEach { renderings += it.rendering }
225+
.launchIn(this + workflowJob)
226+
assertEquals(
227+
listOf(
228+
"rendering1: initial"
229+
),
230+
renderings
231+
)
232+
assertEquals(1, childWorkflow.starts)
233+
234+
trigger.value = "foo"
235+
assertEquals(1, childWorkflow.starts)
236+
assertEquals(
237+
listOf(
238+
"rendering1: initial",
239+
"rendering1: foo"
240+
),
241+
renderings
242+
)
243+
244+
props.value = 1
245+
// Start another child workflow node.
246+
assertEquals(2, childWorkflow.starts)
247+
assertEquals(
248+
listOf(
249+
"rendering1: initial",
250+
"rendering1: foo",
251+
// 2 renderings, 1 for the props, the 2nd for the worker triggering on the new instance.
252+
"rendering2: foo",
253+
"rendering2: foo",
254+
),
255+
renderings
256+
)
257+
258+
trigger.value = "bar"
259+
assertEquals(2, childWorkflow.starts)
260+
assertEquals(
261+
listOf(
262+
"rendering1: initial",
263+
"rendering1: foo",
264+
"rendering2: foo",
197265
"rendering2: foo",
198-
"rendering2: bar"
266+
"rendering2: bar",
199267
),
200268
renderings
201269
)

0 commit comments

Comments
 (0)