Skip to content

Commit 9f2152b

Browse files
Add Interceptor Methods for Skip and Update Rendering
1 parent 6e2a691 commit 9f2152b

File tree

6 files changed

+232
-8
lines changed

6 files changed

+232
-8
lines changed

workflow-runtime/api/workflow-runtime.api

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup
44
public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
55
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
66
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
7+
public fun onRenderingSkipped ()V
8+
public fun onRenderingUpdated (Lcom/squareup/workflow1/RenderingAndSnapshot;)Lcom/squareup/workflow1/RenderingAndSnapshot;
79
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
810
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
911
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -32,6 +34,8 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar
3234
public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
3335
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
3436
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
37+
public fun onRenderingSkipped ()V
38+
public fun onRenderingUpdated (Lcom/squareup/workflow1/RenderingAndSnapshot;)Lcom/squareup/workflow1/RenderingAndSnapshot;
3539
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
3640
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
3741
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -54,6 +58,8 @@ public abstract interface class com/squareup/workflow1/WorkflowInterceptor {
5458
public abstract fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
5559
public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
5660
public abstract fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
61+
public abstract fun onRenderingSkipped ()V
62+
public abstract fun onRenderingUpdated (Lcom/squareup/workflow1/RenderingAndSnapshot;)Lcom/squareup/workflow1/RenderingAndSnapshot;
5763
public abstract fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
5864
public abstract fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
5965
public abstract fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -64,6 +70,8 @@ public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls {
6470
public static fun onPropsChanged (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
6571
public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
6672
public static fun onRenderAndSnapshot (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
73+
public static fun onRenderingSkipped (Lcom/squareup/workflow1/WorkflowInterceptor;)V
74+
public static fun onRenderingUpdated (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/RenderingAndSnapshot;)Lcom/squareup/workflow1/RenderingAndSnapshot;
6775
public static fun onSessionStarted (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
6876
public static fun onSnapshotState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
6977
public static fun onSnapshotStateWithChildren (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
131131
// coroutine to calculate the initial rendering.
132132
val renderingsAndSnapshots = MutableStateFlow(
133133
try {
134-
runner.nextRendering()
134+
chainedInterceptor.onRenderingUpdated(runner.nextRendering())
135135
} catch (e: Throwable) {
136136
// If any part of the workflow runtime fails, the scope should be cancelled. We're not in a
137137
// coroutine yet however, so if the first render pass fails it won't cancel the runtime,
@@ -174,7 +174,9 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
174174
actionChainingHasChangedState: Boolean = false
175175
): Boolean {
176176
return runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) &&
177-
actionResult is ActionApplied<*> && !actionResult.stateChanged && !actionChainingHasChangedState
177+
actionResult is ActionApplied<*> &&
178+
!actionResult.stateChanged &&
179+
!actionChainingHasChangedState
178180
}
179181

180182
scope.launch {
@@ -185,6 +187,7 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
185187
var actionResult: ActionProcessingResult = runner.processAction()
186188

187189
if (shouldShortCircuitForUnchangedState(actionResult)) {
190+
chainedInterceptor.onRenderingSkipped()
188191
sendOutput(actionResult, onOutput)
189192
continue@outer
190193
}
@@ -222,6 +225,7 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
222225
actionChainingHasChangedState = actionChainingHasChangedState
223226
)
224227
) {
228+
chainedInterceptor.onRenderingSkipped()
225229
sendOutput(drainingActionResult, onOutput)
226230
continue@outer
227231
}
@@ -253,6 +257,7 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
253257
actionChainingHasChangedState = actionChainingHasChangedState
254258
)
255259
) {
260+
chainedInterceptor.onRenderingSkipped()
256261
sendOutput(actionResult, onOutput)
257262
continue@outer
258263
}
@@ -265,7 +270,7 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
265270
}
266271

267272
// Pass on the rendering to the UI.
268-
renderingsAndSnapshots.value = nextRenderAndSnapshot
273+
renderingsAndSnapshots.value = chainedInterceptor.onRenderingUpdated(nextRenderAndSnapshot)
269274

270275
// Emit the Output
271276
sendOutput(actionResult, onOutput)

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

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import kotlin.reflect.KType
1212
* Provides hooks into the workflow runtime that can be used to instrument or modify the behavior
1313
* of workflows.
1414
*
15-
* This interface's methods mirror the methods of [StatefulWorkflow]. It also has one additional
16-
* method, [onSessionStarted], that is notified when a workflow is started. Each method returns the
17-
* same thing as the corresponding method on [StatefulWorkflow], and receives the same parameters
18-
* as well as two extra parameters:
15+
* This interface's methods mirror the methods of [StatefulWorkflow], and three additional methods,
16+
* explained below.
17+
* Each method returns the same thing as the corresponding method on [StatefulWorkflow], and\
18+
* receives the same parameters as well as two extra parameters:
1919
*
2020
* - **`proceed`** – A function that _exactly_ mirrors the corresponding function on
2121
* [StatefulWorkflow], accepting the same parameters and returning the same thing. An interceptor
@@ -28,6 +28,19 @@ import kotlin.reflect.KType
2828
*
2929
* All methods have default no-op implementations.
3030
*
31+
* ## Additional Methods
32+
*
33+
* There are 3 more methods in this interface:
34+
*
35+
* 1. [onSessionStarted] - called when a new [WorkflowSession] is created the first time a
36+
* workflow is rendered with the [CoroutineScope] for that session.
37+
* 1. [onRenderingSkipped] - called when the rendering is short-circuited because no state
38+
* change was detected as part of the action cascade(s) and the
39+
* [RuntimeConfig.RENDER_ONLY_WHEN_STATE_CHANGES] is enabled.
40+
* 1. [onRenderingUpdated] - called when a [RenderingAndSnapshot] is passed to the [StateFlow]
41+
* returned by [renderWorkflowIn]. It is passed the [RenderingAndSnapshot] which could be
42+
* decorated.
43+
*
3144
* ## On Profiling
3245
*
3346
* Note that the [WorkflowInterceptor]'s methods will call the actual methods with the proceed
@@ -129,6 +142,21 @@ public interface WorkflowInterceptor {
129142
session: WorkflowSession
130143
): Snapshot? = proceed(state)
131144

145+
/**
146+
* Called when a [RenderingAndSnapshot] is updated from the runtime (i.e. when the [StateFlow]
147+
* is provided a new value - this may still be de-duplicated).
148+
*
149+
* @return [RenderingAndSnapshot]: a possibly modified [RenderingAndSnapshot].
150+
*/
151+
public fun <R> onRenderingUpdated(
152+
rendering: RenderingAndSnapshot<R>
153+
): RenderingAndSnapshot<R> = rendering
154+
155+
/**
156+
* Called when the rendering is skipped because no state has changed after action cascade(s).
157+
*/
158+
public fun onRenderingSkipped(): Unit = Unit
159+
132160
/**
133161
* Information about the session of a workflow in the runtime that a [WorkflowInterceptor] method
134162
* is intercepting.

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

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class RenderWorkflowInTest {
102102
runTest(dispatcher) {
103103
val props = MutableStateFlow("foo")
104104
val workflow = Workflow.stateless<String, Nothing, String> { "props: $it" }
105-
// Don't allow the workflow runtime to actually start.
105+
// Don't allow the workflow runtime to actually start if this is a [StandardTestDispatcher].
106106

107107
val renderings = renderWorkflowIn(
108108
workflow = workflow,
@@ -116,6 +116,78 @@ class RenderWorkflowInTest {
116116
}
117117
}
118118

119+
@Test fun initial_rendering_is_reported_through_interceptor() {
120+
runtimeTestRunner.runParametrizedTest(
121+
paramSource = runtimeOptions,
122+
before = ::setup,
123+
) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) ->
124+
runTest(dispatcher) {
125+
val props = MutableStateFlow("foo")
126+
val workflow = Workflow.stateless<String, Nothing, String> { "props: $it" }
127+
128+
val testInterceptor = object : WorkflowInterceptor {
129+
override fun <R> onRenderingUpdated(
130+
rendering: RenderingAndSnapshot<R>
131+
): RenderingAndSnapshot<R> {
132+
@Suppress("UNCHECKED_CAST")
133+
return (rendering as? RenderingAndSnapshot<String>)?.let {
134+
val snapshot = rendering.snapshot
135+
val renderingString = rendering.rendering
136+
RenderingAndSnapshot("$renderingString+update", snapshot) as RenderingAndSnapshot<R>
137+
} ?: rendering
138+
}
139+
}
140+
141+
val renderings = renderWorkflowIn(
142+
workflow = workflow,
143+
scope = backgroundScope,
144+
props = props,
145+
interceptors = listOf(testInterceptor),
146+
runtimeConfig = runtimeConfig,
147+
workflowTracer = workflowTracer,
148+
) {}
149+
assertEquals("props: foo+update", renderings.value.rendering)
150+
}
151+
}
152+
}
153+
154+
@Test fun modified_rendering_is_returned() {
155+
runtimeTestRunner.runParametrizedTest(
156+
paramSource = runtimeOptions,
157+
before = ::setup,
158+
) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) ->
159+
runTest(dispatcher) {
160+
val props = MutableStateFlow("foo")
161+
val workflow = Workflow.stateless<String, Nothing, String> { "props: $it" }
162+
163+
val interceptedRenderings = mutableListOf<Any?>()
164+
val testInterceptor = object : WorkflowInterceptor {
165+
override fun <R> onRenderingUpdated(
166+
rendering: RenderingAndSnapshot<R>
167+
): RenderingAndSnapshot<R> {
168+
interceptedRenderings.add(rendering.rendering)
169+
return rendering
170+
}
171+
}
172+
173+
renderWorkflowIn(
174+
workflow = workflow,
175+
scope = backgroundScope,
176+
props = props,
177+
interceptors = listOf(testInterceptor),
178+
runtimeConfig = runtimeConfig,
179+
workflowTracer = workflowTracer,
180+
) {}
181+
assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.")
182+
assertEquals(
183+
"props: foo",
184+
interceptedRenderings[0],
185+
"Should intercept 'props: foo' as a rendering."
186+
)
187+
}
188+
}
189+
}
190+
119191
@Test fun initial_rendering_is_calculated_when_scope_cancelled_before_start() {
120192
runtimeTestRunner.runParametrizedTest(
121193
paramSource = runtimeOptions,
@@ -229,6 +301,55 @@ class RenderWorkflowInTest {
229301
}
230302
}
231303

304+
@Test fun new_renderings_are_emitted_to_interceptor() {
305+
runtimeTestRunner.runParametrizedTest(
306+
paramSource = runtimeOptions,
307+
before = ::setup,
308+
) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) ->
309+
runTest(dispatcher) {
310+
val props = MutableStateFlow("foo")
311+
val workflow = Workflow.stateless<String, Nothing, String> { "props: $it" }
312+
313+
val interceptedRenderings = mutableListOf<Any?>()
314+
val testInterceptor = object : WorkflowInterceptor {
315+
override fun <R> onRenderingUpdated(
316+
rendering: RenderingAndSnapshot<R>
317+
): RenderingAndSnapshot<R> {
318+
interceptedRenderings.add(rendering.rendering)
319+
return rendering
320+
}
321+
}
322+
323+
renderWorkflowIn(
324+
workflow = workflow,
325+
scope = backgroundScope,
326+
props = props,
327+
interceptors = listOf(testInterceptor),
328+
runtimeConfig = runtimeConfig,
329+
workflowTracer = workflowTracer,
330+
) {}
331+
advanceIfStandard(dispatcher)
332+
333+
assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.")
334+
assertEquals(
335+
"props: foo",
336+
interceptedRenderings[0],
337+
"Should intercept 'props: foo' as a rendering."
338+
)
339+
340+
props.value = "bar"
341+
advanceIfStandard(dispatcher)
342+
343+
assertEquals(2, interceptedRenderings.size, "Should have intercepted 2 rendering.")
344+
assertEquals(
345+
"props: bar",
346+
interceptedRenderings[1],
347+
"Should intercept 'props: bar' as a rendering."
348+
)
349+
}
350+
}
351+
}
352+
232353
private val runtimeMatrix: Sequence<Triple<RuntimeConfig, RuntimeConfig, TestDispatcher>> =
233354
cartesianProduct(
234355
runtimes.asSequence(),
@@ -1194,6 +1315,64 @@ class RenderWorkflowInTest {
11941315
}
11951316
}
11961317

1318+
@Test fun for_render_on_state_change_only_we_report_skipped_in_interceptor() {
1319+
runtimeTestRunner.runParametrizedTest(
1320+
paramSource = runtimeOptions.filter {
1321+
it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES)
1322+
},
1323+
before = ::setup,
1324+
) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) ->
1325+
runTest(dispatcher) {
1326+
check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES))
1327+
lateinit var sink: Sink<String>
1328+
val interceptedRenderings = mutableListOf<Any?>()
1329+
var skippedRenderings = 0
1330+
val testInterceptor = object : WorkflowInterceptor {
1331+
override fun <R> onRenderingUpdated(
1332+
rendering: RenderingAndSnapshot<R>
1333+
): RenderingAndSnapshot<R> {
1334+
interceptedRenderings.add(rendering.rendering)
1335+
return rendering
1336+
}
1337+
1338+
override fun onRenderingSkipped() {
1339+
skippedRenderings++
1340+
}
1341+
}
1342+
1343+
val workflow = Workflow.stateful<Unit, String, Nothing, String>(
1344+
initialState = { "unchanging state" },
1345+
render = { _, renderState ->
1346+
sink = actionSink.contraMap { action("") { state = it } }
1347+
renderState
1348+
}
1349+
)
1350+
val props = MutableStateFlow(Unit)
1351+
val renderings = renderWorkflowIn(
1352+
workflow = workflow,
1353+
scope = backgroundScope,
1354+
props = props,
1355+
interceptors = listOf(testInterceptor),
1356+
runtimeConfig = runtimeConfig,
1357+
workflowTracer = workflowTracer,
1358+
) {}
1359+
1360+
val emitted = mutableListOf<RenderingAndSnapshot<String>>()
1361+
val collectionJob = launch {
1362+
renderings.collect { emitted += it }
1363+
}
1364+
1365+
sink.send("unchanging state")
1366+
advanceIfStandard(dispatcher)
1367+
collectionJob.cancel()
1368+
1369+
assertEquals(1, emitted.size)
1370+
assertEquals(1, interceptedRenderings.size)
1371+
assertEquals(1, skippedRenderings)
1372+
}
1373+
}
1374+
}
1375+
11971376
@Test fun for_render_on_state_change_only_we_render_if_state_changed() {
11981377
runtimeTestRunner.runParametrizedTest(
11991378
paramSource = runtimeOptions.filter {

workflow-testing/api/workflow-testing.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com
1111
public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
1212
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
1313
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
14+
public fun onRenderingSkipped ()V
15+
public fun onRenderingUpdated (Lcom/squareup/workflow1/RenderingAndSnapshot;)Lcom/squareup/workflow1/RenderingAndSnapshot;
1416
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
1517
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
1618
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;

0 commit comments

Comments
 (0)