Skip to content

Commit 99ab6eb

Browse files
Merge pull request #233 from square/zachklipp/teardown
Add teardown hook on WorkflowContext.
2 parents 541c054 + 31a60a3 commit 99ab6eb

File tree

7 files changed

+157
-7
lines changed

7 files changed

+157
-7
lines changed

kotlin/workflow-core/src/main/java/com/squareup/workflow/Workflow.kt

+8
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ package com.squareup.workflow
5151
* or renderings, extend [StatelessWorkflow], or just pass a lambda to the [stateless] function
5252
* below.
5353
*
54+
* ## Interacting with Events and Other Workflows
55+
*
56+
* All workflows are passed a [WorkflowContext] in their compose methods. This context allows the
57+
* workflow to interact with the outside world by doing things like listening for events,
58+
* subscribing to streams of data, rendering child workflows, and performing cleanup when the
59+
* workflow is about to be torn down by its parent. See the documentation on [WorkflowContext] for
60+
* more information about what it can do.
61+
*
5462
* @param InputT Typically a data class that is used to pass configuration information or bits of
5563
* state that the workflow can always get from its parent and needn't duplicate in its own state.
5664
* May be [Unit] if the workflow does not need any input data.

kotlin/workflow-core/src/main/java/com/squareup/workflow/WorkflowContext.kt

+16
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import kotlin.reflect.KType
4040
* ## Composing Children
4141
*
4242
* See [compose].
43+
*
44+
* ## Handling Workflow Teardown
45+
*
46+
* See [onTeardown].
4347
*/
4448
interface WorkflowContext<StateT : Any, in OutputT : Any> {
4549

@@ -126,6 +130,18 @@ interface WorkflowContext<StateT : Any, in OutputT : Any> {
126130
key: String = "",
127131
handler: (ChildOutputT) -> WorkflowAction<StateT, OutputT>
128132
): ChildRenderingT
133+
134+
/**
135+
* Adds an action to be invoked if the workflow is discarded by its parent before the next
136+
* compose pass. Multiple hooks can be registered in the same compose pass, they will be invoked
137+
* in the same order they're set. Like any other work performed through the context (e.g. calls
138+
* to [compose] or [onReceive]), hooks are cleared at the start of each compose pass, so you must
139+
* set any hooks you need in each compose pass.
140+
*
141+
* Teardown handlers should be non-blocking and execute quickly, since they are invoked
142+
* synchronously during the compose pass.
143+
*/
144+
fun onTeardown(handler: () -> Unit)
129145
}
130146

131147
/**

kotlin/workflow-host/src/main/java/com/squareup/workflow/internal/Behavior.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import kotlin.reflect.KType
3333
internal data class Behavior<StateT : Any, out OutputT : Any>(
3434
val childCases: List<WorkflowOutputCase<*, *, StateT, OutputT>>,
3535
val subscriptionCases: List<SubscriptionCase<*, StateT, OutputT>>,
36-
val nextActionFromEvent: Deferred<WorkflowAction<StateT, OutputT>>
36+
val nextActionFromEvent: Deferred<WorkflowAction<StateT, OutputT>>,
37+
val teardownHooks: List<() -> Unit>
3738
) {
3839

3940
// @formatter:off

kotlin/workflow-host/src/main/java/com/squareup/workflow/internal/RealWorkflowContext.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ internal class RealWorkflowContext<StateT : Any, OutputT : Any>(
4646
private val nextUpdateFromEvent = CompletableDeferred<WorkflowAction<StateT, OutputT>>()
4747
private val subscriptionCases = mutableListOf<SubscriptionCase<*, StateT, OutputT>>()
4848
private val childCases = mutableListOf<WorkflowOutputCase<*, *, StateT, OutputT>>()
49+
private val teardownHooks = mutableListOf<() -> Unit>()
4950

5051
override fun <EventT : Any> onEvent(handler: (EventT) -> WorkflowAction<StateT, OutputT>):
5152
EventHandler<EventT> {
@@ -88,12 +89,17 @@ internal class RealWorkflowContext<StateT : Any, OutputT : Any>(
8889
return composer.compose(case, child, id, input)
8990
}
9091

92+
override fun onTeardown(handler: () -> Unit) {
93+
teardownHooks += handler
94+
}
95+
9196
/**
9297
* Constructs an immutable [Behavior] from the context.
9398
*/
9499
fun buildBehavior(): Behavior<StateT, OutputT> = Behavior(
95100
childCases = childCases.toList(),
96101
subscriptionCases = subscriptionCases.toList(),
97-
nextActionFromEvent = nextUpdateFromEvent
102+
nextActionFromEvent = nextUpdateFromEvent,
103+
teardownHooks = teardownHooks.toList()
98104
)
99105
}

kotlin/workflow-host/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt

+20-5
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ internal class WorkflowNode<InputT : Any, StateT : Any, OutputT : Any, Rendering
7171
*/
7272
override val coroutineContext = baseContext + Job(baseContext[Job]) + CoroutineName(id.toString())
7373

74+
init {
75+
// This will get invoked whenever we are cancelled (via our cancel() method), or whenever
76+
// any of our ancestor workflows are cancelled, anywhere up the tree, including whenever an
77+
// exception is thrown that cancels a workflow.
78+
coroutineContext[Job]!!.invokeOnCompletion {
79+
behavior?.teardownHooks?.forEach { it.invoke() }
80+
}
81+
}
82+
7483
private val subtreeManager = SubtreeManager<StateT, OutputT>(coroutineContext)
7584
private val subscriptionTracker =
7685
LifetimeTracker<SubscriptionCase<*, StateT, OutputT>, Any, Subscription>(
@@ -85,7 +94,7 @@ internal class WorkflowNode<InputT : Any, StateT : Any, OutputT : Any, Rendering
8594

8695
private var lastInput: InputT = initialInput
8796

88-
private lateinit var behavior: Behavior<StateT, OutputT>
97+
private var behavior: Behavior<StateT, OutputT>? = null
8998

9099
/**
91100
* Walk the tree of workflows, rendering each one and using
@@ -149,7 +158,7 @@ internal class WorkflowNode<InputT : Any, StateT : Any, OutputT : Any, Rendering
149158

150159
// Listen for any events.
151160
with(selector) {
152-
behavior.nextActionFromEvent.onAwait { update ->
161+
behavior!!.nextActionFromEvent.onAwait { update ->
153162
acceptUpdate(update)
154163
}
155164
}
@@ -162,6 +171,10 @@ internal class WorkflowNode<InputT : Any, StateT : Any, OutputT : Any, Rendering
162171
* after calling this method.
163172
*/
164173
fun cancel() {
174+
// No other cleanup work should be done in this function, since it will only be invoked when
175+
// this workflow is *directly* discarded by its parent (or the host).
176+
// If you need to do something whenever this workflow is torn down, add it to the
177+
// invokeOnCompletion handler for the Job above.
165178
coroutineContext.cancel()
166179
}
167180

@@ -179,9 +192,11 @@ internal class WorkflowNode<InputT : Any, StateT : Any, OutputT : Any, Rendering
179192
val rendering = workflow.compose(input, state, context)
180193

181194
behavior = context.buildBehavior()
182-
// Start new children/subscriptions, and drop old ones.
183-
subtreeManager.track(behavior.childCases)
184-
subscriptionTracker.track(behavior.subscriptionCases)
195+
.apply {
196+
// Start new children/subscriptions, and drop old ones.
197+
subtreeManager.track(childCases)
198+
subscriptionTracker.track(subscriptionCases)
199+
}
185200

186201
return rendering
187202
}

kotlin/workflow-host/src/test/java/com/squareup/workflow/internal/WorkflowNodeTest.kt

+18
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.squareup.workflow.internal
1818
import com.squareup.workflow.EventHandler
1919
import com.squareup.workflow.Snapshot
2020
import com.squareup.workflow.StatefulWorkflow
21+
import com.squareup.workflow.Workflow
2122
import com.squareup.workflow.WorkflowAction.Companion.emitOutput
2223
import com.squareup.workflow.WorkflowAction.Companion.enterState
2324
import com.squareup.workflow.WorkflowContext
@@ -26,6 +27,7 @@ import com.squareup.workflow.invoke
2627
import com.squareup.workflow.onReceive
2728
import com.squareup.workflow.parse
2829
import com.squareup.workflow.readUtf8WithLength
30+
import com.squareup.workflow.stateless
2931
import com.squareup.workflow.util.ChannelUpdate
3032
import com.squareup.workflow.util.ChannelUpdate.Closed
3133
import com.squareup.workflow.util.ChannelUpdate.Value
@@ -604,4 +606,20 @@ class WorkflowNodeTest {
604606
)
605607
assertEquals("input:new input|state:initial input", restoredNode.compose(workflow, "foo"))
606608
}
609+
610+
@Test fun `invokes teardown hooks in order on cancel`() {
611+
val teardowns = mutableListOf<Int>()
612+
val workflow = Workflow.stateless<Nothing, Unit> { context ->
613+
context.onTeardown { teardowns += 1 }
614+
context.onTeardown { teardowns += 2 }
615+
}
616+
val node = WorkflowNode(workflow.id(), workflow.asStatefulWorkflow(), Unit, null, Unconfined)
617+
node.compose(workflow.asStatefulWorkflow(), Unit)
618+
619+
assertTrue(teardowns.isEmpty())
620+
621+
node.cancel()
622+
623+
assertEquals(listOf(1, 2), teardowns)
624+
}
607625
}

kotlin/workflow-testing/src/test/java/com/squareup/workflow/CompositionIntegrationTest.kt

+86
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
*/
1616
package com.squareup.workflow
1717

18+
import com.squareup.workflow.WorkflowAction.Companion.enterState
1819
import com.squareup.workflow.testing.testFromStart
1920
import kotlin.test.Test
2021
import kotlin.test.assertEquals
2122
import kotlin.test.assertFailsWith
23+
import kotlin.test.assertTrue
2224

2325
class CompositionIntegrationTest {
2426

@@ -91,4 +93,88 @@ class CompositionIntegrationTest {
9193
}
9294
}
9395
}
96+
97+
@Test fun `all childrens teardown hooks invoked when parent discards it`() {
98+
val teardowns = mutableListOf<String>()
99+
val child1 = Workflow.stateless<Nothing, Unit> { context ->
100+
context.onTeardown { teardowns += "child1" }
101+
}
102+
val child2 = Workflow.stateless<Nothing, Unit> { context ->
103+
context.onTeardown { teardowns += "child2" }
104+
}
105+
// A workflow that will render child1 and child2 until its rendering is invoked, at which point
106+
// it will compose neither of them, which should trigger the teardown callbacks.
107+
val root = object : StatefulWorkflow<Unit, Boolean, Nothing, () -> Unit>() {
108+
override fun initialState(
109+
input: Unit,
110+
snapshot: Snapshot?
111+
): Boolean = true
112+
113+
override fun compose(
114+
input: Unit,
115+
state: Boolean,
116+
context: WorkflowContext<Boolean, Nothing>
117+
): () -> Unit {
118+
if (state) {
119+
context.compose(child1, key = "child1")
120+
context.compose(child2, key = "child2")
121+
}
122+
return context.onEvent<Unit> { enterState(false) }::invoke
123+
}
124+
125+
override fun snapshotState(state: Boolean): Snapshot = Snapshot.EMPTY
126+
}
127+
128+
root.testFromStart { tester ->
129+
tester.withNextRendering { teardownChildren ->
130+
assertTrue(teardowns.isEmpty())
131+
132+
teardownChildren()
133+
134+
assertEquals(listOf("child1", "child2"), teardowns)
135+
}
136+
}
137+
}
138+
139+
@Test fun `nested childrens teardown hooks invoked when parent discards it`() {
140+
val teardowns = mutableListOf<String>()
141+
val grandchild = Workflow.stateless<Nothing, Unit> { context ->
142+
context.onTeardown { teardowns += "grandchild" }
143+
}
144+
val child = Workflow.stateless<Nothing, Unit> { context ->
145+
context.compose(grandchild)
146+
context.onTeardown { teardowns += "child" }
147+
}
148+
// A workflow that will render child1 and child2 until its rendering is invoked, at which point
149+
// it will compose neither of them, which should trigger the teardown callbacks.
150+
val root = object : StatefulWorkflow<Unit, Boolean, Nothing, () -> Unit>() {
151+
override fun initialState(
152+
input: Unit,
153+
snapshot: Snapshot?
154+
): Boolean = true
155+
156+
override fun compose(
157+
input: Unit,
158+
state: Boolean,
159+
context: WorkflowContext<Boolean, Nothing>
160+
): () -> Unit {
161+
if (state) {
162+
context.compose(child)
163+
}
164+
return context.onEvent<Unit> { enterState(false) }::invoke
165+
}
166+
167+
override fun snapshotState(state: Boolean): Snapshot = Snapshot.EMPTY
168+
}
169+
170+
root.testFromStart { tester ->
171+
tester.withNextRendering { teardownChildren ->
172+
assertTrue(teardowns.isEmpty())
173+
174+
teardownChildren()
175+
176+
assertEquals(listOf("grandchild", "child"), teardowns)
177+
}
178+
}
179+
}
94180
}

0 commit comments

Comments
 (0)