Skip to content

Commit 8fefe33

Browse files
Add Conflate Stale Renderings Runtime
Check for empty channels and synchronously return if they are empty.
1 parent f8af85b commit 8fefe33

File tree

13 files changed

+231
-37
lines changed

13 files changed

+231
-37
lines changed

.github/workflows/kotlin.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ jobs :
100100
with :
101101
report_paths : '**/build/test-results/test/TEST-*.xml'
102102

103+
jvm-conflate-runtime-test :
104+
name : Conflate Stale Renderings Runtime JVM Tests
105+
runs-on : ubuntu-latest
106+
timeout-minutes : 20
107+
steps :
108+
- uses : actions/checkout@v3
109+
- uses : gradle/wrapper-validation-action@v1
110+
- name : set up JDK 11
111+
uses : actions/setup-java@v3
112+
with :
113+
distribution : 'zulu'
114+
java-version : 11
115+
116+
## Actual task
117+
- uses : gradle/gradle-build-action@v2
118+
name : Check with Gradle
119+
with :
120+
arguments : |
121+
jvmTest --stacktrace --continue -Pworkflow.runtime=conflate
122+
cache-read-only : false
123+
124+
# Report as Github Pull Request Check.
125+
- name : Publish Test Report
126+
uses : mikepenz/action-junit-report@v3
127+
if : always() # always run even if the previous step fails
128+
with :
129+
report_paths : '**/build/test-results/test/TEST-*.xml'
130+
103131
ios-tests :
104132
name : iOS Tests
105133
runs-on : macos-latest
@@ -228,6 +256,54 @@ jobs :
228256
name : instrumentation-test-results-${{ matrix.api-level }}
229257
path : ./**/build/reports/androidTests/connected/**
230258

259+
conflate-renderings-instrumentation-tests :
260+
name : Conflate Stale Renderings Instrumentation tests
261+
runs-on : macos-latest
262+
timeout-minutes : 45
263+
strategy :
264+
# Allow tests to continue on other devices if they fail on one device.
265+
fail-fast : false
266+
matrix :
267+
api-level :
268+
- 29
269+
# Unclear that older versions actually honor command to disable animation.
270+
# Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222
271+
steps :
272+
- uses : actions/checkout@v3
273+
- name : set up JDK 11
274+
uses : actions/setup-java@v3
275+
with :
276+
distribution : 'zulu'
277+
java-version : 11
278+
279+
## Build before running tests, using cache.
280+
- uses: gradle/gradle-build-action@v2
281+
name : Build instrumented tests
282+
with :
283+
# Unfortunately I don't think we can key this cache based on our project property so
284+
# we clean and rebuild.
285+
arguments : |
286+
clean assembleDebugAndroidTest --stacktrace -Pworkflow.runtime=conflate
287+
cache-read-only: false
288+
289+
## Actual task
290+
- name : Instrumentation Tests
291+
uses : reactivecircus/android-emulator-runner@v2
292+
with :
293+
# @ychescale9 suspects Galaxy Nexus is the fastest one
294+
profile : Galaxy Nexus
295+
api-level : ${{ matrix.api-level }}
296+
arch : x86_64
297+
# Skip the benchmarks as this is running on emulators
298+
script : ./gradlew connectedCheck -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck --stacktrace -Pworkflow.runtime=conflate
299+
300+
- name : Upload results
301+
if : ${{ always() }}
302+
uses : actions/upload-artifact@v3
303+
with :
304+
name : instrumentation-test-results-${{ matrix.api-level }}
305+
path : ./**/build/reports/androidTests/connected/**
306+
231307
upload-to-mobiledev :
232308
name : mobile.dev | Build & Upload
233309
runs-on : ubuntu-latest

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

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

33
import com.squareup.workflow1.RuntimeConfig
4+
import com.squareup.workflow1.RuntimeConfig.ConflateStaleRenderings
45
import com.squareup.workflow1.RuntimeConfig.RenderPerAction
56
import com.squareup.workflow1.WorkflowExperimentalRuntime
67

@@ -17,12 +18,15 @@ public class AndroidRuntimeConfigTools {
1718
* this function, and then pass that to the call to [renderWorkflowIn] as the [RuntimeConfig].
1819
*
1920
* Current options are:
21+
* "conflate" : [ConflateStaleRenderings] Process all queued actions before passing rendering
22+
* to the UI layer.
2023
* "baseline" : [RenderPerAction] Original Workflow Runtime. Note that this doesn't need to
2124
* be specified as it is the current default and is assumed by this utility.
2225
*/
2326
@WorkflowExperimentalRuntime
2427
public fun getAppWorkflowRuntimeConfig(): RuntimeConfig {
2528
return when (BuildConfig.WORKFLOW_RUNTIME) {
29+
"conflate" -> ConflateStaleRenderings
2630
else -> RenderPerAction
2731
}
2832
}

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

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

33
import com.squareup.workflow1.RuntimeConfig
4+
import com.squareup.workflow1.RuntimeConfig.ConflateStaleRenderings
45
import com.squareup.workflow1.RuntimeConfig.RenderPerAction
56
import com.squareup.workflow1.WorkflowExperimentalRuntime
67

@@ -16,16 +17,18 @@ public class JvmTestRuntimeConfigTools {
1617
* [RuntimeConfig].
1718
*
1819
* Current options are:
20+
* "conflate" : [ConflateStaleRenderings] Process all queued actions before passing rendering
21+
* to the UI layer.
1922
* "baseline" : [RenderPerAction] Original Workflow Runtime. Note that this doesn't need to
2023
* be specified as it is the current default and is assumed by this utility.
2124
*/
2225
@OptIn(WorkflowExperimentalRuntime::class)
2326
public fun getTestRuntimeConfig(): RuntimeConfig {
24-
return RenderPerAction
25-
// val runtimeConfig = System.getProperty("workflow.runtime", "baseline")
26-
// return when (runtimeConfig) {
27-
// else -> RenderPerAction
28-
// }
27+
val runtimeConfig = System.getProperty("workflow.runtime", "baseline")
28+
return when (runtimeConfig) {
29+
"conflate" -> ConflateStaleRenderings
30+
else -> RenderPerAction
31+
}
2932
}
3033
}
3134
}

workflow-core/api/workflow-core.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
public abstract interface class com/squareup/workflow1/ActionProcessingResult {
22
}
33

4+
public final class com/squareup/workflow1/ActionsExhausted : com/squareup/workflow1/ActionProcessingResult {
5+
public static final field INSTANCE Lcom/squareup/workflow1/ActionsExhausted;
6+
}
7+
48
public abstract interface class com/squareup/workflow1/BaseRenderContext {
59
public abstract fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9;
610
public abstract fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ public sealed interface ActionProcessingResult
119119

120120
public object PropsUpdated : ActionProcessingResult
121121

122+
public object ActionsExhausted : ActionProcessingResult
123+
122124
/** Wrapper around a potentially-nullable [OutputT] value. */
123125
public class WorkflowOutput<out OutputT>(public val value: OutputT) : ActionProcessingResult {
124126
override fun toString(): String = "WorkflowOutput($value)"

workflow-runtime/api/workflow-runtime.api

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ public final class com/squareup/workflow1/RuntimeConfig$Companion {
2828
public final fun getDEFAULT_CONFIG ()Lcom/squareup/workflow1/RuntimeConfig;
2929
}
3030

31+
public final class com/squareup/workflow1/RuntimeConfig$ConflateStaleRenderings : com/squareup/workflow1/RuntimeConfig {
32+
public static final field INSTANCE Lcom/squareup/workflow1/RuntimeConfig$ConflateStaleRenderings;
33+
}
34+
3135
public final class com/squareup/workflow1/RuntimeConfig$RenderPerAction : com/squareup/workflow1/RuntimeConfig {
3236
public static final field INSTANCE Lcom/squareup/workflow1/RuntimeConfig$RenderPerAction;
3337
}
@@ -195,7 +199,7 @@ public final class com/squareup/workflow1/internal/SubtreeManager : com/squareup
195199
public final fun commitRenderedChildren ()V
196200
public final fun createChildSnapshots ()Ljava/util/Map;
197201
public fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
198-
public final fun tickChildren (Lkotlinx/coroutines/selects/SelectBuilder;)V
202+
public final fun tickChildren (Lkotlinx/coroutines/selects/SelectBuilder;)Z
199203
}
200204

201205
public final class com/squareup/workflow1/internal/SystemUtilsKt {
@@ -235,7 +239,7 @@ public final class com/squareup/workflow1/internal/WorkflowNode : com/squareup/w
235239
public final fun render (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;)Ljava/lang/Object;
236240
public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V
237241
public final fun snapshot (Lcom/squareup/workflow1/StatefulWorkflow;)Lcom/squareup/workflow1/TreeSnapshot;
238-
public final fun tick (Lkotlinx/coroutines/selects/SelectBuilder;)V
242+
public final fun tick (Lkotlinx/coroutines/selects/SelectBuilder;)Z
239243
public fun toString ()Ljava/lang/String;
240244
}
241245

@@ -272,6 +276,7 @@ public final class com/squareup/workflow1/internal/WorkflowRunner {
272276
public final fun cancelRuntime (Ljava/util/concurrent/CancellationException;)V
273277
public static synthetic fun cancelRuntime$default (Lcom/squareup/workflow1/internal/WorkflowRunner;Ljava/util/concurrent/CancellationException;ILjava/lang/Object;)V
274278
public final fun nextRendering ()Lcom/squareup/workflow1/RenderingAndSnapshot;
275-
public final fun processAction (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
279+
public final fun processAction (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
280+
public static synthetic fun processAction$default (Lcom/squareup/workflow1/internal/WorkflowRunner;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
276281
}
277282

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

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

3+
import com.squareup.workflow1.RuntimeConfig.ConflateStaleRenderings
34
import com.squareup.workflow1.internal.WorkflowRunner
45
import com.squareup.workflow1.internal.chained
56
import kotlinx.coroutines.CancellationException
@@ -101,7 +102,7 @@ import kotlinx.coroutines.launch
101102
* A [StateFlow] of [RenderingAndSnapshot]s that will emit any time the root workflow creates a new
102103
* rendering.
103104
*/
104-
@OptIn(ExperimentalCoroutinesApi::class)
105+
@OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class)
105106
public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
106107
workflow: Workflow<PropsT, OutputT, RenderingT>,
107108
scope: CoroutineScope,
@@ -133,21 +134,60 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
133134
}
134135
)
135136

137+
suspend fun <PropsT, OutputT, RenderingT> renderAndEmitOutput(
138+
runner: WorkflowRunner<PropsT, OutputT, RenderingT>,
139+
actionResult: ActionProcessingResult?,
140+
onOutput: suspend (OutputT) -> Unit
141+
): RenderingAndSnapshot<RenderingT> {
142+
val nextRenderAndSnapshot = runner.nextRendering()
143+
144+
when (actionResult) {
145+
is WorkflowOutput<*> -> {
146+
@Suppress("UNCHECKED_CAST")
147+
(actionResult as? WorkflowOutput<OutputT>)?.let {
148+
onOutput(it.value)
149+
}
150+
}
151+
else -> {} // no -op
152+
}
153+
154+
return nextRenderAndSnapshot
155+
}
156+
136157
scope.launch {
137158
while (isActive) {
138-
// It might look weird to start by consuming the output before getting the rendering below,
159+
lateinit var nextRenderAndSnapshot: RenderingAndSnapshot<RenderingT>
160+
// It might look weird to start by processing an action before getting the rendering below,
139161
// but remember the first render pass already occurred above, before this coroutine was even
140162
// launched.
141-
val output: WorkflowOutput<OutputT>? = runner.processAction()
163+
var actionResult: ActionProcessingResult? = runner.processAction()
142164

143-
// After resuming from runner.nextOutput() our coroutine could now be cancelled, check so we
144-
// don't surprise anyone with an unexpected rendering pass. Show's over, go home.
165+
// After resuming from runner.processAction() our coroutine could now be cancelled, check so
166+
// we don't surprise anyone with an unexpected rendering pass. Show's over, go home.
145167
if (!isActive) return@launch
146168

147-
// After receiving an output, the next render pass must be done before emitting that output,
148-
// so that the workflow states appear consistent to observers of the outputs and renderings.
149-
renderingsAndSnapshots.value = runner.nextRendering()
150-
output?.let { onOutput(it.value) }
169+
// If the action did produce an Output, we send it immediately after the render pass.
170+
nextRenderAndSnapshot = renderAndEmitOutput(runner, actionResult, onOutput)
171+
172+
if (runtimeConfig == ConflateStaleRenderings) {
173+
// With this runtime modification, we do not pass renderings we know to be stale. This
174+
// means that we may be calling onOutput out of sync with the update of the UI. Output
175+
// is an event though, and should always occur immediately - i.e. it cannot be stale.
176+
while (actionResult != ActionsExhausted) {
177+
// We have more actions we can process, so this rendering is stale.
178+
actionResult = runner.processAction(waitForAnAction = false)
179+
180+
if (!isActive) return@launch
181+
182+
// If no actions processed, then no new rendering needed.
183+
if (actionResult == ActionsExhausted) break
184+
185+
nextRenderAndSnapshot = renderAndEmitOutput(runner, actionResult, onOutput)
186+
}
187+
}
188+
189+
// Pass on to the UI.
190+
renderingsAndSnapshots.value = nextRenderAndSnapshot
151191
}
152192
}
153193

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ public sealed interface RuntimeConfig {
2525
*/
2626
public object RenderPerAction : RuntimeConfig
2727

28+
/**
29+
* If we have more actions to process, do so before passing the rendering to the UI layer.
30+
*/
31+
@WorkflowExperimentalRuntime
32+
public object ConflateStaleRenderings : RuntimeConfig
33+
2834
public companion object {
2935
public val DEFAULT_CONFIG: RuntimeConfig = RenderPerAction
3036
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,17 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
133133
/**
134134
* Uses [selector] to invoke [WorkflowNode.tick] for every running child workflow this instance
135135
* is managing.
136+
*
137+
* @return [Boolean] whether or not the children action queues are empty.
136138
*/
137-
fun tickChildren(selector: SelectBuilder<ActionProcessingResult?>) {
139+
fun tickChildren(selector: SelectBuilder<ActionProcessingResult?>): Boolean {
140+
var empty = true
138141
children.forEachActive { child ->
139-
child.workflowNode.tick(selector)
142+
// Do this separately so the compiler doesn't avoid it if empty is already false.
143+
val childEmpty = child.workflowNode.tick(selector)
144+
empty = childEmpty && empty
140145
}
146+
return empty
141147
}
142148

143149
fun createChildSnapshots(): Map<WorkflowNodeId, TreeSnapshot> {

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import kotlinx.coroutines.CancellationException
1818
import kotlinx.coroutines.CoroutineName
1919
import kotlinx.coroutines.CoroutineScope
2020
import kotlinx.coroutines.CoroutineStart.LAZY
21+
import kotlinx.coroutines.ExperimentalCoroutinesApi
2122
import kotlinx.coroutines.Job
2223
import kotlinx.coroutines.cancel
2324
import kotlinx.coroutines.channels.Channel
@@ -142,24 +143,32 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
142143
}
143144

144145
/**
145-
* Gets the next [output][OutputT] from the state machine.
146+
* Gets the next [result][ActionProcessingResult] from the state machine. This will be an
147+
* [OutputT] or null.
146148
*
147149
* Walk the tree of state machines, asking each one to wait for its next event. If something happen
148150
* that results in an output, that output is returned. Null means something happened that requires
149151
* a re-render, e.g. my state changed or a child state changed.
150152
*
151153
* It is an error to call this method after calling [cancel].
154+
*
155+
* @return [Boolean] whether or not the queues were empty for this node and its children at the
156+
* time of suspending.
152157
*/
153-
fun tick(selector: SelectBuilder<ActionProcessingResult?>) {
158+
@OptIn(ExperimentalCoroutinesApi::class)
159+
fun tick(selector: SelectBuilder<ActionProcessingResult?>): Boolean {
154160
// Listen for any child workflow updates.
155-
subtreeManager.tickChildren(selector)
161+
var empty = subtreeManager.tickChildren(selector)
162+
163+
empty = empty && (eventActionsChannel.isEmpty || eventActionsChannel.isClosedForReceive)
156164

157165
// Listen for any events.
158166
with(selector) {
159167
eventActionsChannel.onReceive { action ->
160168
return@onReceive applyAction(action)
161169
}
162170
}
171+
return empty
163172
}
164173

165174
/**

0 commit comments

Comments
 (0)