Skip to content

Commit a3d67b6

Browse files
1247: Partial Tree Rerendering
If PARTIAL_TREE_RENDERING is in the RuntimeConfig then, Track whether or not the state (or state of a descendant) changed in the WorkflowNode. Pass lastRendering if its not. Also adds tests for this behavior and expands the test runtime matrix. Also adds shards for the new runtime for instrumentation tests.
1 parent ce54070 commit a3d67b6

File tree

9 files changed

+313
-26
lines changed

9 files changed

+313
-26
lines changed

.github/workflows/kotlin.yml

+46-2
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,27 @@ jobs :
222222
with :
223223
report_paths : '**/build/test-results/test/TEST-*.xml'
224224

225+
jvm-partial-runtime-test:
226+
name: Partial Tree Rendering Only Runtime JVM Tests
227+
runs-on: ubuntu-latest
228+
timeout-minutes: 20
229+
steps:
230+
- name: Checkout
231+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
232+
233+
- name: Check with Gradle
234+
uses: ./.github/actions/gradle-task
235+
with:
236+
task: jvmTest --continue -Pworkflow.runtime=baseline-partial
237+
restore-cache-key: main-build-artifacts
238+
239+
# Report as GitHub Pull Request Check.
240+
- name: Publish Test Report
241+
uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4
242+
if: always() # always run even if the previous step fails
243+
with:
244+
report_paths: '**/build/test-results/test/TEST-*.xml'
245+
225246
jvm-conflate-stateChange-runtime-test :
226247
name : Render On State Change Only and Conflate Stale Runtime JVM Tests
227248
runs-on : ubuntu-latest
@@ -243,6 +264,27 @@ jobs :
243264
with :
244265
report_paths : '**/build/test-results/test/TEST-*.xml'
245266

267+
jvm-conflate-partial-runtime-test:
268+
name: Render On State Change Only and Conflate Stale Runtime JVM Tests
269+
runs-on: ubuntu-latest
270+
timeout-minutes: 20
271+
steps:
272+
- name: Checkout
273+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
274+
275+
- name: Check with Gradle
276+
uses: ./.github/actions/gradle-task
277+
with:
278+
task: jvmTest --continue -Pworkflow.runtime=conflate-partial
279+
restore-cache-key: main-build-artifacts
280+
281+
# Report as GitHub Pull Request Check.
282+
- name: Publish Test Report
283+
uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4
284+
if: always() # always run even if the previous step fails
285+
with:
286+
report_paths: '**/build/test-results/test/TEST-*.xml'
287+
246288
ios-tests :
247289
name : iOS Tests
248290
runs-on : macos-latest
@@ -349,7 +391,7 @@ jobs :
349391
### <start-connected-check-shards>
350392
shardNum: [ 1, 2, 3 ]
351393
### <end-connected-check-shards>
352-
runtime : [ conflate, baseline-stateChange, conflate-stateChange ]
394+
runtime : [ conflate, baseline-stateChange, conflate-stateChange, baseline-partial, conflate-partial ]
353395
steps :
354396
- name: Checkout
355397
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
@@ -378,8 +420,10 @@ jobs :
378420
- ios-tests
379421
- js-tests
380422
- jvm-conflate-runtime-test
381-
- jvm-conflate-stateChange-runtime-test
382423
- jvm-stateChange-runtime-test
424+
- jvm-partial-runtime-test
425+
- jvm-conflate-stateChange-runtime-test
426+
- jvm-conflate-partial-runtime-test
383427
- ktlint
384428
- performance-tests
385429
- runtime-instrumentation-tests

workflow-config/config-android/src/main/java/com/squareup/workflow1/config/AndroidRuntimeConfigTools.kt

+10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.squareup.workflow1.config
33
import com.squareup.workflow1.RuntimeConfig
44
import com.squareup.workflow1.RuntimeConfigOptions
55
import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS
6+
import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING
67
import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES
78
import com.squareup.workflow1.WorkflowExperimentalRuntime
89

@@ -27,6 +28,9 @@ public class AndroidRuntimeConfigTools {
2728
* Then, these can be combined (via '-') with:
2829
* "stateChange" : Only re-render when the state of some WorkflowNode has been changed by an
2930
* action cascade.
31+
* "partial" : Which includes "stateChange" as well as partial tree rendering, which only
32+
* re-renders each Workflow node if: 1) its state changed; or 2) one of its descendant's state
33+
* changed.
3034
*
3135
* E.g., "baseline-stateChange" to turn on the stateChange option with the baseline runtime.
3236
*
@@ -37,6 +41,12 @@ public class AndroidRuntimeConfigTools {
3741
"conflate" -> setOf(CONFLATE_STALE_RENDERINGS)
3842
"conflate-stateChange" -> setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES)
3943
"baseline-stateChange" -> setOf(RENDER_ONLY_WHEN_STATE_CHANGES)
44+
"conflate-partial" -> setOf(
45+
CONFLATE_STALE_RENDERINGS,
46+
RENDER_ONLY_WHEN_STATE_CHANGES,
47+
PARTIAL_TREE_RENDERING
48+
)
49+
"baseline-partial" -> setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING)
4050
"", "baseline" -> RuntimeConfigOptions.RENDER_PER_ACTION
4151
else ->
4252
throw IllegalArgumentException("Unrecognized config \"${BuildConfig.WORKFLOW_RUNTIME}\"")

workflow-config/config-jvm/src/main/java/com/squareup/workflow1/config/JvmTestRuntimeConfigTools.kt

+10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.squareup.workflow1.config
33
import com.squareup.workflow1.RuntimeConfig
44
import com.squareup.workflow1.RuntimeConfigOptions
55
import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS
6+
import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING
67
import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES
78
import com.squareup.workflow1.WorkflowExperimentalRuntime
89

@@ -27,6 +28,9 @@ public class JvmTestRuntimeConfigTools {
2728
* Then, these can be combined (via '-') with:
2829
* "stateChange" : Only re-render when the state of some WorkflowNode has been changed by an
2930
* action cascade.
31+
* "partial" : Which includes "stateChange" as well as partial tree rendering, which only
32+
* re-renders each Workflow node if: 1) its state changed; or 2) one of its descendant's state
33+
* changed.
3034
*
3135
* E.g., "baseline-stateChange" to turn on the stateChange option with the baseline runtime.
3236
*
@@ -38,6 +42,12 @@ public class JvmTestRuntimeConfigTools {
3842
"conflate" -> setOf(CONFLATE_STALE_RENDERINGS)
3943
"conflate-stateChange" -> setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES)
4044
"baseline-stateChange" -> setOf(RENDER_ONLY_WHEN_STATE_CHANGES)
45+
"conflate-partial" -> setOf(
46+
CONFLATE_STALE_RENDERINGS,
47+
RENDER_ONLY_WHEN_STATE_CHANGES,
48+
PARTIAL_TREE_RENDERING
49+
)
50+
"baseline-partial" -> setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING)
4151
"", "baseline" -> RuntimeConfigOptions.RENDER_PER_ACTION
4252
else ->
4353
throw IllegalArgumentException("Unrecognized config \"$runtimeConfig\"")

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

+16
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ public enum class RuntimeConfigOptions {
3838
@WorkflowExperimentalRuntime
3939
RENDER_ONLY_WHEN_STATE_CHANGES,
4040

41+
/**
42+
* Only re-render each active Workflow node if:
43+
* 1. It's own state changed, OR
44+
* 2. One of it's descendant's state has changed.
45+
*
46+
* Otherwise return the cached rendering (as there is no way it could have changed).
47+
*
48+
* Note however that you must be careful using this because there may be external
49+
* state that your Workflow's draw in and re-render and if that is not explicitly
50+
* tracked within that Workflow's state, then it will not re-render. In this case,
51+
* make sure that the state is tracked within the Workflow's state (even through
52+
* an artificial token) in some way.
53+
*/
54+
@WorkflowExperimentalRuntime
55+
PARTIAL_TREE_RENDERING,
56+
4157
/**
4258
* If we have more actions to process, do so before passing the rendering to the UI layer.
4359
*/

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

+65-18
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import com.squareup.workflow1.NoopWorkflowInterceptor
66
import com.squareup.workflow1.RenderContext
77
import com.squareup.workflow1.RuntimeConfig
88
import com.squareup.workflow1.RuntimeConfigOptions
9+
import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING
10+
import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES
911
import com.squareup.workflow1.StatefulWorkflow
1012
import com.squareup.workflow1.TreeSnapshot
1113
import com.squareup.workflow1.Workflow
1214
import com.squareup.workflow1.WorkflowAction
1315
import com.squareup.workflow1.WorkflowExperimentalApi
16+
import com.squareup.workflow1.WorkflowExperimentalRuntime
1417
import com.squareup.workflow1.WorkflowIdentifier
1518
import com.squareup.workflow1.WorkflowInterceptor
1619
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
@@ -33,6 +36,7 @@ import kotlinx.coroutines.launch
3336
import kotlinx.coroutines.plus
3437
import kotlinx.coroutines.selects.SelectBuilder
3538
import kotlin.coroutines.CoroutineContext
39+
import kotlin.jvm.JvmInline
3640

3741
/**
3842
* A node in a state machine tree. Manages the actual state for a given [Workflow].
@@ -44,7 +48,7 @@ import kotlin.coroutines.CoroutineContext
4448
* hard-coded values added to worker contexts. It must not contain a [Job] element (it would violate
4549
* structured concurrency).
4650
*/
47-
@OptIn(WorkflowExperimentalApi::class)
51+
@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class)
4852
internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
4953
val id: WorkflowNodeId,
5054
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>,
@@ -86,9 +90,11 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
8690
)
8791
private val sideEffects = ActiveStagingList<SideEffectNode>()
8892
private var lastProps: PropsT = initialProps
93+
private var lastRendering: Box<RenderingT> = Box()
8994
private val eventActionsChannel =
9095
Channel<WorkflowAction<PropsT, StateT, OutputT>>(capacity = UNLIMITED)
9196
private var state: StateT
97+
private var subtreeStateDidChange: Boolean = true
9298

9399
private val baseRenderContext = RealRenderContext(
94100
renderer = subtreeManager,
@@ -211,12 +217,14 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
211217
*/
212218
private fun updateCachedWorkflowInstance(
213219
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>
214-
) {
220+
): Boolean {
215221
if (workflow !== cachedWorkflowInstance) {
216222
// The instance has changed.
217223
cachedWorkflowInstance = workflow
218224
interceptedWorkflowInstance = interceptor.intercept(cachedWorkflowInstance, this)
225+
return true
219226
}
227+
return false
220228
}
221229

222230
/**
@@ -227,39 +235,59 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
227235
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>,
228236
props: PropsT
229237
): RenderingT {
230-
updateCachedWorkflowInstance(workflow)
231-
updatePropsAndState(props)
238+
val didUpdateCachedInstance = updatePropsAndState(props, workflow)
232239

233-
baseRenderContext.unfreeze()
234-
val rendering = interceptedWorkflowInstance.render(props, state, context)
235-
baseRenderContext.freeze()
240+
if (!runtimeConfig.contains(PARTIAL_TREE_RENDERING) ||
241+
!lastRendering.isInitialized ||
242+
subtreeStateDidChange
243+
) {
244+
if (!didUpdateCachedInstance) {
245+
// If we haven't already updated the cached instance, better do it now!
246+
updateCachedWorkflowInstance(workflow)
247+
}
248+
baseRenderContext.unfreeze()
249+
lastRendering = Box(interceptedWorkflowInstance.render(props, state, context))
250+
baseRenderContext.freeze()
236251

237-
workflowTracer.trace("UpdateRuntimeTree") {
238-
// Tear down workflows and workers that are obsolete.
239-
subtreeManager.commitRenderedChildren()
240-
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
241-
// be started after context is frozen.
242-
sideEffects.forEachStaging { it.job.start() }
243-
sideEffects.commitStaging { it.job.cancel() }
252+
workflowTracer.trace("UpdateRuntimeTree") {
253+
// Tear down workflows and workers that are obsolete.
254+
subtreeManager.commitRenderedChildren()
255+
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
256+
// be started after context is frozen.
257+
sideEffects.forEachStaging { it.job.start() }
258+
sideEffects.commitStaging { it.job.cancel() }
259+
}
260+
// After we have rendered this subtree, we need another action in order for us to be
261+
// considered dirty again.
262+
subtreeStateDidChange = false
244263
}
245264

246-
return rendering
265+
return lastRendering.getOrThrow()
247266
}
248267

268+
/**
269+
* @return true if the [interceptedWorkflowInstance] has been updated, false otherwise.
270+
*/
249271
private fun updatePropsAndState(
250-
newProps: PropsT
251-
) {
272+
newProps: PropsT,
273+
workflow: StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>,
274+
): Boolean {
275+
var didUpdateCachedInstance = false
252276
if (newProps != lastProps) {
277+
didUpdateCachedInstance = updateCachedWorkflowInstance(workflow)
253278
val newState = interceptedWorkflowInstance.onPropsChanged(lastProps, newProps, state)
254279
state = newState
280+
subtreeStateDidChange = true
255281
}
256282
lastProps = newProps
283+
return didUpdateCachedInstance
257284
}
258285

259286
/**
260287
* Applies [action] to this workflow's [state] and then passes the resulting [ActionApplied]
261288
* via [emitAppliedActionToParent] to the parent, with additional information as to whether or
262289
* not this action has changed the current node's state.
290+
*
263291
*/
264292
private fun applyAction(
265293
action: WorkflowAction<PropsT, StateT, OutputT>,
@@ -272,7 +300,13 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
272300
// Changing state is sticky, we pass it up if it ever changed.
273301
stateChanged = actionApplied.stateChanged || (childResult?.stateChanged ?: false)
274302
)
275-
return if (actionApplied.output != null) {
303+
// Our state changed or one of our children's state changed.
304+
subtreeStateDidChange = aggregateActionApplied.stateChanged
305+
return if (actionApplied.output != null ||
306+
runtimeConfig.contains(PARTIAL_TREE_RENDERING)
307+
) {
308+
// If we are using the optimization, always return to the parent, so we carry a path that
309+
// notes that the subtree did change all the way to the root.
276310
emitAppliedActionToParent(aggregateActionApplied)
277311
} else {
278312
aggregateActionApplied
@@ -289,4 +323,17 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
289323
SideEffectNode(key, job)
290324
}
291325
}
326+
327+
@JvmInline
328+
internal value class Box<T>(private val _value: Any? = Uninitialized) {
329+
val isInitialized: Boolean get() = _value !== Uninitialized
330+
331+
@Suppress("UNCHECKED_CAST")
332+
fun getOrThrow(): T {
333+
check(isInitialized)
334+
return _value as T
335+
}
336+
}
337+
338+
internal object Uninitialized
292339
}

0 commit comments

Comments
 (0)