Skip to content

Commit 78662ad

Browse files
Add WorkflowInterceptor API to intercept ComposeWorkflow render passes.
1 parent 3f1dca8 commit 78662ad

File tree

4 files changed

+117
-44
lines changed

4 files changed

+117
-44
lines changed

samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflow.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.squareup.sample.hellocomposeworkflow.HelloComposeWorkflow.State.Goodb
99
import com.squareup.sample.hellocomposeworkflow.HelloComposeWorkflow.State.Hello
1010
import com.squareup.workflow1.WorkflowExperimentalApi
1111
import com.squareup.workflow1.compose.ComposeWorkflow
12+
import java.util.concurrent.atomic.AtomicInteger
1213

1314
@OptIn(WorkflowExperimentalApi::class)
1415
object HelloComposeWorkflow : ComposeWorkflow<Unit, Nothing, HelloRendering>() {
@@ -23,7 +24,9 @@ object HelloComposeWorkflow : ComposeWorkflow<Unit, Nothing, HelloRendering>() {
2324
emitOutput: (Nothing) -> Unit
2425
): HelloRendering {
2526
var state by remember { mutableStateOf(Hello) }
26-
println("OMG recomposing state=$state")
27+
val compositions = remember { AtomicInteger(0) }
28+
println("OMG recomposing state=$state (count=${compositions.incrementAndGet()})")
29+
2730
return HelloRendering(
2831
message = state.name,
2932
onClick = {

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

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.squareup.workflow1
22

33
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
4+
import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome
45
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Job
@@ -110,6 +111,20 @@ public interface WorkflowInterceptor {
110111
): RenderingAndSnapshot<R> =
111112
proceed(renderProps)
112113

114+
/**
115+
* Called before a workflow is rendered, and before the interceptor's [onRender] method. Every
116+
* call to this method is eventually followed by a call to [onAfterRender].
117+
*
118+
* This method should be preferred over [onRender] since it supports additional types of workflows
119+
* that will not call [onRender].
120+
*/
121+
public fun <P, S> onBeforeRender(
122+
renderProps: P,
123+
renderState: S,
124+
session: WorkflowSession
125+
) {
126+
}
127+
113128
/**
114129
* Intercepts calls to [StatefulWorkflow.render].
115130
*/
@@ -121,6 +136,15 @@ public interface WorkflowInterceptor {
121136
session: WorkflowSession
122137
): R = proceed(renderProps, renderState, null)
123138

139+
/**
140+
* Called after a workflow is rendered, and after the interceptor's [onRender] method. Every
141+
* call to this method is preceded by a call to [onBeforeRender].
142+
*
143+
* This method should be preferred over [onRender] since it supports additional types of workflows
144+
* that will not call [onRender].
145+
*/
146+
public fun <R> onAfterRender(rendering: R) {}
147+
124148
/**
125149
* Intercept calls to [StatefulWorkflow.snapshotState] including the children calls.
126150
* This is useful to intercept a rendering + snapshot traversal for tracing purposes.
@@ -375,29 +399,35 @@ internal fun <P, S, O, R> WorkflowInterceptor.intercept(
375399
renderProps: P,
376400
renderState: S,
377401
context: RenderContext<P, S, O>
378-
): R = onRender(
379-
renderProps,
380-
renderState,
381-
context,
382-
proceed = { props, state, interceptor ->
383-
// The `RenderContext` used *might* change - primarily in the case of our tests. E.g., The
384-
// `RenderTester` uses a special NoOp context to render twice to test for idempotency.
385-
// In order to support a changed render context but keep caching, we check to see if the
386-
// instance passed in has changed.
387-
if (cachedInterceptedRenderContext == null || canonicalRenderContext !== context ||
388-
canonicalRenderContextInterceptor != interceptor
389-
) {
390-
val interceptedRenderContext = interceptor?.let { InterceptedRenderContext(context, it) }
391-
?: context
392-
cachedInterceptedRenderContext = RenderContext(interceptedRenderContext, this)
393-
}
394-
canonicalRenderContext = context
395-
canonicalRenderContextInterceptor = interceptor
396-
// Use the intercepted RenderContext for rendering.
397-
workflow.render(props, state, cachedInterceptedRenderContext!!)
398-
},
399-
session = workflowSession,
400-
)
402+
): R {
403+
onBeforeRender(renderProps, renderState, workflowSession)
404+
return onRender(
405+
renderProps,
406+
renderState,
407+
context,
408+
proceed = { props, state, interceptor ->
409+
// The `RenderContext` used *might* change - primarily in the case of our tests. E.g., The
410+
// `RenderTester` uses a special NoOp context to render twice to test for idempotency.
411+
// In order to support a changed render context but keep caching, we check to see if the
412+
// instance passed in has changed.
413+
if (cachedInterceptedRenderContext == null || canonicalRenderContext !== context ||
414+
canonicalRenderContextInterceptor != interceptor
415+
) {
416+
val interceptedRenderContext =
417+
interceptor?.let { InterceptedRenderContext(context, it) }
418+
?: context
419+
cachedInterceptedRenderContext = RenderContext(interceptedRenderContext, this)
420+
}
421+
canonicalRenderContext = context
422+
canonicalRenderContextInterceptor = interceptor
423+
// Use the intercepted RenderContext for rendering.
424+
workflow.render(props, state, cachedInterceptedRenderContext!!)
425+
},
426+
session = workflowSession,
427+
).also {
428+
onAfterRender(it)
429+
}
430+
}
401431

402432
override fun snapshotState(state: S) =
403433
onSnapshotState(state, workflow::snapshotState, workflowSession)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ internal class ChainedWorkflowInterceptor(
7575
return chainedProceed(renderProps)
7676
}
7777

78+
override fun <P, S> onBeforeRender(
79+
renderProps: P,
80+
renderState: S,
81+
session: WorkflowSession
82+
) {
83+
for (i in interceptors.indices) {
84+
interceptors[i].onBeforeRender(renderProps, renderState, session)
85+
}
86+
}
87+
7888
override fun <P, S, O, R> onRender(
7989
renderProps: P,
8090
renderState: S,
@@ -99,6 +109,13 @@ internal class ChainedWorkflowInterceptor(
99109
return chainedProceed(renderProps, renderState, null)
100110
}
101111

112+
override fun <R> onAfterRender(rendering: R) {
113+
// TODO does this still give optimized bytecode?
114+
for (i in interceptors.indices.reversed()) {
115+
interceptors[i].onAfterRender(rendering)
116+
}
117+
}
118+
102119
override fun onSnapshotStateWithChildren(
103120
proceed: () -> TreeSnapshot,
104121
session: WorkflowSession

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

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
7777
}
7878
}
7979

80-
private var cachedComposeWorkflow by mutableStateOf(workflow)
80+
private var cachedComposeWorkflow: ComposeWorkflow<PropsT, OutputT, RenderingT>? by
81+
mutableStateOf(null)
8182
private var lastProps by mutableStateOf(initialProps)
8283
private var lastRendering = NullableInitBox<RenderingT>()
8384
private val recomposer: Recomposer = Recomposer(coroutineContext)
@@ -196,6 +197,29 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
196197
session = this,
197198
proceed = { _, _, _ -> ComposeWorkflowState }
198199
)
200+
201+
// By not calling setContent directly every time, we ensure that if neither the workflow
202+
// instance nor input changed, we don't recompose.
203+
// setContent will synchronously perform the first recomposition before returning, which is why
204+
// we leave cachedComposeWorkflow null for now: we don't want its produceRendering to be called
205+
// until we're actually doing a render pass.
206+
composition.setContent {
207+
@Suppress("NAME_SHADOWING")
208+
val workflow = cachedComposeWorkflow
209+
if (workflow != null) {
210+
val rendering = workflow.produceRendering(
211+
props = lastProps,
212+
emitOutput = sendOutputToChannel
213+
)
214+
215+
// lastRendering isn't snapshot state, so wait until the composition is applied to update
216+
// it.
217+
SideEffect {
218+
lastRendering = NullableInitBox(rendering)
219+
}
220+
}
221+
}
222+
cachedComposeWorkflow = workflow
199223
}
200224

201225
override fun render(
@@ -221,6 +245,11 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
221245
}
222246
)
223247

248+
interceptor.onBeforeRender(
249+
renderProps = input,
250+
renderState = ComposeWorkflowState,
251+
session = this
252+
)
224253
interceptor.onRender(
225254
session = this,
226255
renderProps = input,
@@ -233,7 +262,9 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
233262
// But we need to be able to support sending actions to support this anyway.
234263
doRender()
235264
}
236-
)
265+
).also {
266+
interceptor.onAfterRender(it)
267+
}
237268
} else {
238269
this.lastProps = input
239270
doRender()
@@ -242,22 +273,6 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
242273

243274
private fun doRender(): RenderingT {
244275
val frameRequest = if (!lastRendering.isInitialized) {
245-
// By not calling setContent directly every time, we ensure that if neither the workflow
246-
// instance nor input changed, we don't recompose.
247-
// setContent will synchronously perform the first recomposition before returning!
248-
composition.setContent {
249-
val rendering = cachedComposeWorkflow.produceRendering(
250-
props = lastProps,
251-
emitOutput = sendOutputToChannel
252-
)
253-
254-
// lastRendering isn't snapshot state, so wait until the composition is applied to update
255-
// it.
256-
SideEffect {
257-
lastRendering = NullableInitBox(rendering)
258-
}
259-
}
260-
261276
// Initial render kicks off the render loop. This should always synchronously request a frame.
262277
startComposition()
263278

@@ -297,8 +312,16 @@ internal class ComposeWorkflowNode<PropsT, OutputT, RenderingT>(
297312
}
298313

299314
override fun snapshot(): TreeSnapshot {
300-
// TODO Support snapshots from rememberSaveable.
301-
return TreeSnapshot(workflowSnapshot = null, childTreeSnapshots = ::emptyMap)
315+
return interceptor.onSnapshotStateWithChildren(
316+
session = this,
317+
proceed = {
318+
// Compose workflows do not support the onSnapshotState interceptor since they don't
319+
// distinguish between snapshot state objects for themselves and their child
320+
// ComposeWorkflows.
321+
// TODO Support snapshots from rememberSaveable.
322+
TreeSnapshot(workflowSnapshot = null, childTreeSnapshots = ::emptyMap)
323+
}
324+
)
302325
}
303326

304327
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)

0 commit comments

Comments
 (0)