Skip to content

Commit 92c81b5

Browse files
WIP: Intercept to freeze for sideEffects
1 parent 1d85e47 commit 92c81b5

File tree

6 files changed

+63
-22
lines changed

6 files changed

+63
-22
lines changed

workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,14 @@ import kotlinx.coroutines.plus
1414
import kotlinx.coroutines.sync.Mutex
1515
import kotlinx.coroutines.test.UnconfinedTestDispatcher
1616
import kotlinx.coroutines.test.runTest
17-
import org.junit.Ignore
1817
import org.junit.Test
1918
import kotlin.test.assertEquals
2019
import kotlin.test.assertTrue
2120

2221
@OptIn(WorkflowExperimentalRuntime::class, ExperimentalCoroutinesApi::class)
2322
class AndroidRenderWorkflowInTest {
2423

25-
@Ignore("#1311: Does not yet work with immediate dispatcher.")
24+
// @Ignore("#1311: Does not yet work with immediate dispatcher.")
2625
@Test
2726
fun with_conflate_we_conflate_stacked_actions_into_one_rendering() =
2827
runTest(UnconfinedTestDispatcher()) {

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,23 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
100100
* Freezes this context so that any further calls to this context will throw.
101101
*/
102102
fun freeze() {
103-
checkNotFrozen("freeze") { "freeze" }
103+
// checkNotFrozen("freeze") { "freeze" }
104104
frozen = true
105105
}
106106

107+
/**
108+
* Freezes this context if it is not currently frozen.
109+
*
110+
* @return Boolean of whether or not the context was already frozen.
111+
*/
112+
internal fun freezeIfNotFrozen(): Boolean {
113+
if (!frozen) {
114+
freeze()
115+
return false
116+
}
117+
return true
118+
}
119+
107120
/**
108121
* Unfreezes when the node is about to render() again.
109122
*/

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import com.squareup.workflow1.trace
2626
import kotlinx.coroutines.CancellationException
2727
import kotlinx.coroutines.CoroutineName
2828
import kotlinx.coroutines.CoroutineScope
29-
import kotlinx.coroutines.CoroutineStart.LAZY
29+
import kotlinx.coroutines.CoroutineStart.DEFAULT
3030
import kotlinx.coroutines.DelicateCoroutinesApi
3131
import kotlinx.coroutines.ExperimentalCoroutinesApi
3232
import kotlinx.coroutines.Job
@@ -36,7 +36,10 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
3636
import kotlinx.coroutines.launch
3737
import kotlinx.coroutines.plus
3838
import kotlinx.coroutines.selects.SelectBuilder
39+
import kotlin.coroutines.Continuation
40+
import kotlin.coroutines.ContinuationInterceptor
3941
import kotlin.coroutines.CoroutineContext
42+
import kotlin.coroutines.CoroutineContext.Key
4043
import kotlin.reflect.KType
4144

4245
/**
@@ -279,9 +282,6 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
279282
workflowTracer.trace("UpdateRuntimeTree") {
280283
// Tear down workflows and workers that are obsolete.
281284
subtreeManager.commitRenderedChildren()
282-
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
283-
// be started after context is frozen.
284-
sideEffects.forEachStaging { it.job.start() }
285285
sideEffects.commitStaging { it.job.cancel() }
286286
remembered.commitStaging { /* Nothing to clean up. */ }
287287
}
@@ -351,13 +351,42 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
351351
}
352352
}
353353

354+
inner class FrozenContextContinuationInterceptor : ContinuationInterceptor {
355+
override val key: Key<*>
356+
get() = ContinuationInterceptor.Key
357+
358+
private var originallyFrozen = false
359+
360+
override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
361+
object : Continuation<T> {
362+
override val context: CoroutineContext
363+
get() = continuation.context
364+
365+
override fun resumeWith(result: Result<T>) {
366+
originallyFrozen = baseRenderContext.freezeIfNotFrozen()
367+
continuation.resumeWith(result)
368+
}
369+
}
370+
371+
override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
372+
if (!originallyFrozen) {
373+
baseRenderContext.unfreeze()
374+
}
375+
}
376+
}
377+
354378
private fun createSideEffectNode(
355379
key: String,
356380
sideEffect: suspend CoroutineScope.() -> Unit
357381
): SideEffectNode {
358382
return workflowTracer.trace("CreateSideEffectNode") {
359-
val scope = this + CoroutineName("sideEffect[$key] for $id")
360-
val job = scope.launch(start = LAZY, block = sideEffect)
383+
val scope = this +
384+
CoroutineName("sideEffect[$key] for $id") +
385+
// Adds the ContinuationInterceptor that freezes whenever we dispatch the continuation
386+
// for the side effect. This allows us to schedule side effects during the render
387+
// pass.
388+
FrozenContextContinuationInterceptor()
389+
val job = scope.launch(start = DEFAULT, block = sideEffect)
361390
SideEffectNode(key, job)
362391
}
363392
}

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ class RenderWorkflowInTest {
198198
}
199199

200200
@Test
201-
fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_scope_cancelled_before_start`() {
201+
fun `side_effects_from_initial_rendering_in_root_workflow_are_started_when_scope_cancelled_before_start`() {
202202
runtimeTestRunner.runParametrizedTest(
203203
paramSource = runtimeOptions,
204204
before = ::setup,
@@ -222,13 +222,13 @@ class RenderWorkflowInTest {
222222
) {}
223223
advanceIfStandard(dispatcher)
224224

225-
assertFalse(sideEffectWasRan)
225+
assertTrue(sideEffectWasRan)
226226
}
227227
}
228228
}
229229

230230
@Test
231-
fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_scope_cancelled_before_start`() {
231+
fun `side_effects_from_initial_rendering_in_non_root_workflow_are_started_when_scope_cancelled_before_start`() {
232232
runtimeTestRunner.runParametrizedTest(
233233
paramSource = runtimeOptions,
234234
before = ::setup,
@@ -255,7 +255,7 @@ class RenderWorkflowInTest {
255255
) {}
256256
advanceIfStandard(dispatcher)
257257

258-
assertFalse(sideEffectWasRan)
258+
assertTrue(sideEffectWasRan)
259259
}
260260
}
261261
}
@@ -765,7 +765,7 @@ class RenderWorkflowInTest {
765765
}
766766

767767
@Test
768-
fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_initial_render_of_root_workflow_fails`() {
768+
fun `side_effects_from_initial_rendering_in_root_workflow_are_started_when_initial_render_of_root_workflow_fails`() {
769769
runtimeTestRunner.runParametrizedTest(
770770
paramSource = runtimeOptions,
771771
before = ::setup,
@@ -788,7 +788,7 @@ class RenderWorkflowInTest {
788788
workflowTracer = workflowTracer,
789789
) {}
790790
}
791-
assertFalse(sideEffectWasRan)
791+
assertTrue(sideEffectWasRan)
792792
}
793793
}
794794
}
@@ -838,7 +838,7 @@ class RenderWorkflowInTest {
838838
}
839839

840840
@Test
841-
fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_initial_render_of_non_root_workflow_fails`() {
841+
fun `side_effects_from_initial_rendering_in_non_root_workflow_are_started_when_initial_render_of_non_root_workflow_fails`() {
842842
runtimeTestRunner.runParametrizedTest(
843843
paramSource = runtimeOptions,
844844
before = ::setup,
@@ -864,7 +864,7 @@ class RenderWorkflowInTest {
864864
workflowTracer = workflowTracer,
865865
) {}
866866
}
867-
assertFalse(sideEffectWasRan)
867+
assertTrue(sideEffectWasRan)
868868
}
869869
}
870870
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,6 @@ internal class RealRenderContextTest {
220220

221221
val child = Workflow.stateless<Unit, Nothing, Unit> { fail() }
222222
assertFailsWith<IllegalStateException> { context.renderChild(child) }
223-
assertFailsWith<IllegalStateException> { context.freeze() }
224223
assertFailsWith<IllegalStateException> { context.remember("key", typeOf<String>()) {} }
225224
}
226225

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,14 @@ internal class WorkflowNodeTest {
276276
sink.send(action("") { setOutput("event2") })
277277
}
278278

279-
@Test fun sideEffect_is_not_started_until_after_render_completes() {
279+
@Test fun sideEffect_is_started_immediately() {
280280
var started = false
281281
val workflow = Workflow.stateless<Unit, Nothing, Unit> {
282282
runningSideEffect("key") {
283283
started = true
284284
}
285-
assertFalse(started)
285+
// This is because context uses and Unconfined Dispatcher, so is eagerly entered.
286+
assertTrue(started)
286287
}
287288
val node = WorkflowNode(
288289
workflow.id(),
@@ -466,8 +467,8 @@ internal class WorkflowNodeTest {
466467

467468
@Test fun multiple_sideEffects_with_same_key_throws() {
468469
val workflow = Workflow.stateless<Unit, Nothing, Unit> {
469-
runningSideEffect("same") { fail() }
470-
runningSideEffect("same") { fail() }
470+
runningSideEffect("same") { }
471+
runningSideEffect("same") { }
471472
}
472473
val node = WorkflowNode(
473474
workflow.id(),

0 commit comments

Comments
 (0)