Skip to content

Commit 1ae90bb

Browse files
authored
Merge pull request #282 from square/zachklipp/workflowhost-inputs
Add the ability to update the input for a WorkflowHost.
2 parents fc4af15 + cf3ae7b commit 1ae90bb

File tree

21 files changed

+487
-159
lines changed

21 files changed

+487
-159
lines changed

kotlin/samples/tictactoe/common/src/test/java/com/squareup/sample/gameworkflow/TakeTurnsWorkflowTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,6 @@ class TakeTurnsWorkflowTest {
6969
}
7070
}
7171

72-
private fun WorkflowTester<*, GamePlayScreen>.takeSquare(event: TakeSquare) {
72+
private fun WorkflowTester<*, *, GamePlayScreen>.takeSquare(event: TakeSquare) {
7373
withNextRendering { it.onEvent(event) }
7474
}

kotlin/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/MainWorkflowTest.kt

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class MainWorkflowTest {
4040
}
4141

4242
MainWorkflow(authWorkflow, runGameWorkflow()).testFromStart { tester ->
43+
tester.withNextRendering { screen ->
44+
assertThat(screen.panels).containsOnly(DEFAULT_AUTH)
45+
}
4346
tester.withNextRendering { screen ->
4447
assertThat(screen.panels).isEmpty()
4548
assertThat(screen.body).isEqualTo(DEFAULT_RUN_GAME)

kotlin/settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ include ':samples:tictactoe:common'
2323
include ':workflow-core'
2424
include ':workflow-runtime'
2525
include ':workflow-rx2'
26+
include ':workflow-rx2-runtime'
2627
include ':workflow-testing'
2728
include ':workflow-ui-core'
2829
include ':workflow-ui-android'

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

+10-7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.squareup.workflow.util.ChannelUpdate
2121
import com.squareup.workflow.util.ChannelUpdate.Value
2222
import com.squareup.workflow.util.KTypes
2323
import kotlinx.coroutines.CoroutineScope
24+
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
2425
import kotlinx.coroutines.Deferred
2526
import kotlinx.coroutines.channels.ReceiveChannel
2627
import kotlinx.coroutines.channels.produce
@@ -259,6 +260,9 @@ fun <T, StateT : Any, OutputT : Any> WorkflowContext<StateT, OutputT>.onDeferred
259260
* This function is provided as a helper for writing [WorkflowContext] extension functions, it
260261
* should not be used by general application code.
261262
*
263+
* The suspending function will be executed in the current stack frame ([UNDISPATCHED]). When this
264+
* workflow is being torn down, the coroutine running the function will be cancelled.
265+
*
262266
* @param type The [KType] that represents both the type of data source (e.g. `Deferred`) and the
263267
* element type [T].
264268
* @param key An optional string key that is used to distinguish between subscriptions of the same
@@ -289,10 +293,9 @@ fun <T, StateT : Any, OutputT : Any> WorkflowContext<StateT, OutputT>.onSuspendi
289293
*/
290294
private fun <T> CoroutineScope.wrapInNeverClosingChannel(
291295
function: suspend () -> T
292-
): ReceiveChannel<T> =
293-
produce {
294-
send(function())
295-
// We explicitly don't want to close the channel, because that would trigger an infinite loop.
296-
// Instead, just suspend forever.
297-
suspendCancellableCoroutine<Nothing> { }
298-
}
296+
): ReceiveChannel<T> = produce {
297+
send(function())
298+
// We explicitly don't want to close the channel, because that would trigger an infinite loop.
299+
// Instead, just suspend forever.
300+
suspendCancellableCoroutine<Nothing> { }
301+
}

kotlin/workflow-runtime/src/main/java/com/squareup/workflow/WorkflowHost.kt

+101-50
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,29 @@ package com.squareup.workflow
1919

2020
import com.squareup.workflow.WorkflowHost.Factory
2121
import com.squareup.workflow.WorkflowHost.Update
22-
import com.squareup.workflow.internal.WorkflowId
2322
import com.squareup.workflow.internal.WorkflowNode
2423
import com.squareup.workflow.internal.id
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.GlobalScope
26+
import kotlinx.coroutines.InternalCoroutinesApi
27+
import kotlinx.coroutines.Job
28+
import kotlinx.coroutines.NonCancellable.isActive
2529
import kotlinx.coroutines.cancel
30+
import kotlinx.coroutines.channels.Channel
2631
import kotlinx.coroutines.channels.ReceiveChannel
2732
import kotlinx.coroutines.channels.produce
28-
import kotlinx.coroutines.isActive
2933
import kotlinx.coroutines.selects.select
3034
import org.jetbrains.annotations.TestOnly
3135
import kotlin.coroutines.CoroutineContext
3236
import kotlin.coroutines.EmptyCoroutineContext
37+
import kotlin.coroutines.coroutineContext
3338

3439
/**
3540
* Provides a stream of [updates][Update] from a tree of [Workflow]s.
3641
*
3742
* Create these by injecting a [Factory] and calling [run][Factory.run].
3843
*/
39-
interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
44+
interface WorkflowHost<in InputT : Any, out OutputT : Any, out RenderingT : Any> {
4045

4146
/**
4247
* Output from a [WorkflowHost]. Emitted from [WorkflowHost.updates] after every compose pass.
@@ -62,88 +67,134 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
6267
/**
6368
* Creates a [WorkflowHost] to run [workflow].
6469
*
65-
* The workflow's initial state is determined by passing [input] to
66-
* [StatefulWorkflow.initialState].
70+
* The workflow's initial state is determined by passing the first value emitted by [inputs] to
71+
* [StatefulWorkflow.initialState]. Subsequent values emitted from [inputs] will be used to
72+
* re-render the workflow.
6773
*
6874
* @param workflow The workflow to start.
69-
* @param input Passed to [StatefulWorkflow.initialState] to determine the root workflow's
70-
* initial state. If [InputT] is `Unit`, you can just omit this argument.
75+
* @param inputs Passed to [StatefulWorkflow.initialState] to determine the root workflow's
76+
* initial state, and to pass input updates to the root workflow.
77+
* If [InputT] is `Unit`, you can just omit this argument.
7178
* @param snapshot If not null, used to restore the workflow.
7279
* @param context The [CoroutineContext] used to run the workflow tree. Added to the [Factory]'s
7380
* context.
7481
*/
7582
fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
7683
workflow: Workflow<InputT, OutputT, RenderingT>,
77-
input: InputT,
84+
inputs: ReceiveChannel<InputT>,
7885
snapshot: Snapshot? = null,
7986
context: CoroutineContext = EmptyCoroutineContext
80-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, input, snapshot, context)
87+
): WorkflowHost<InputT, OutputT, RenderingT> =
88+
object : WorkflowHost<InputT, OutputT, RenderingT> {
89+
private val scope = CoroutineScope(context)
90+
91+
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
92+
scope.produce(capacity = 0) {
93+
runWorkflowTree(
94+
workflow = workflow.asStatefulWorkflow(),
95+
inputs = inputs,
96+
initialSnapshot = snapshot,
97+
onUpdate = ::send
98+
)
99+
}
100+
}
81101

82102
fun <OutputT : Any, RenderingT : Any> run(
83103
workflow: Workflow<Unit, OutputT, RenderingT>,
84104
snapshot: Snapshot? = null,
85105
context: CoroutineContext = EmptyCoroutineContext
86-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, Unit, snapshot, context)
106+
): WorkflowHost<Unit, OutputT, RenderingT> = run(workflow, channelOf(Unit), snapshot, context)
87107

88108
/**
89109
* Creates a [WorkflowHost] that runs [workflow] starting from [initialState].
90110
*
91111
* **Don't call this directly.**
92112
*
93-
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call the
94-
* testing extension method defined there on your workflow itself.
113+
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call
114+
* the testing extension method defined there on your workflow itself.
95115
*/
96116
@TestOnly
97117
fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runTestFromState(
98118
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
99119
input: InputT,
100120
initialState: StateT
101-
): WorkflowHost<OutputT, RenderingT> {
102-
val workflowId = workflow.id()
103-
return object : WorkflowHost<OutputT, RenderingT> {
104-
val node = WorkflowNode(workflowId, workflow, input, null, baseContext, initialState)
121+
): WorkflowHost<InputT, OutputT, RenderingT> =
122+
object : WorkflowHost<InputT, OutputT, RenderingT> {
105123
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
106-
node.start(workflow, input)
124+
GlobalScope.produce(capacity = 0, context = baseContext) {
125+
runWorkflowTree(
126+
workflow = workflow.asStatefulWorkflow(),
127+
inputs = channelOf(input),
128+
initialSnapshot = null,
129+
initialState = initialState,
130+
onUpdate = ::send
131+
)
132+
}
107133
}
108-
}
109134

110-
internal fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
111-
id: WorkflowId<InputT, OutputT, RenderingT>,
112-
workflow: Workflow<InputT, OutputT, RenderingT>,
113-
input: InputT,
114-
snapshot: Snapshot?,
115-
context: CoroutineContext
116-
): WorkflowHost<OutputT, RenderingT> = object : WorkflowHost<OutputT, RenderingT> {
117-
val node = WorkflowNode(
118-
id = id,
119-
workflow = workflow.asStatefulWorkflow(),
120-
initialInput = input,
121-
snapshot = snapshot,
122-
baseContext = baseContext + context
123-
)
124-
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
125-
node.start(workflow.asStatefulWorkflow(), input)
126-
}
135+
private fun <T> channelOf(value: T) = Channel<T>(capacity = 1)
136+
.apply { offer(value) }
127137
}
128138
}
129139

130140
/**
131-
* Starts the coroutine that runs the coroutine loop.
141+
* Loops forever, or until the coroutine is cancelled, processing the workflow tree and emitting
142+
* updates by calling [onUpdate].
143+
*
144+
* This function is the lowest-level entry point into the runtime. Don't call this directly, instead
145+
* use [WorkflowHost.Factory] to create a [WorkflowHost], or one of the stream operators for your
146+
* favorite Rx library to map a stream of [InputT]s into [Update]s.
132147
*/
133-
internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
134-
workflow: StatefulWorkflow<I, *, O, R>,
135-
input: I
136-
): ReceiveChannel<Update<O, R>> = produce(capacity = 0) {
148+
@UseExperimental(InternalCoroutinesApi::class)
149+
suspend fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runWorkflowTree(
150+
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
151+
inputs: ReceiveChannel<InputT>,
152+
initialSnapshot: Snapshot?,
153+
initialState: StateT? = null,
154+
onUpdate: suspend (Update<OutputT, RenderingT>) -> Unit
155+
): Nothing {
156+
var output: OutputT? = null
157+
var input: InputT = inputs.receive()
158+
var inputsClosed = false
159+
val rootNode = WorkflowNode(
160+
id = workflow.id(),
161+
workflow = workflow,
162+
initialInput = input,
163+
snapshot = initialSnapshot,
164+
baseContext = coroutineContext,
165+
initialState = initialState
166+
)
167+
137168
try {
138-
var output: O? = null
139-
while (isActive) {
140-
val rendering = compose(workflow, input)
141-
val snapshot = snapshot(workflow)
142-
send(Update(rendering, snapshot, output))
169+
while (true) {
170+
// Manually implement `ensureActive()` until we're on coroutines 1.2.x.
171+
if (!isActive) throw coroutineContext[Job]!!.getCancellationException()
172+
173+
val rendering = rootNode.compose(workflow, input)
174+
val snapshot = rootNode.snapshot(workflow)
175+
176+
onUpdate(Update(rendering, snapshot, output))
177+
143178
// Tick _might_ return an output, but if it returns null, it means the state or a child
144179
// probably changed, so we should re-compose/snapshot and emit again.
145180
output = select {
146-
tick(this) { it }
181+
// Stop trying to read from the inputs channel after it's closed.
182+
if (!inputsClosed) {
183+
@Suppress("EXPERIMENTAL_API_USAGE")
184+
inputs.onReceiveOrNull { newInput ->
185+
if (newInput == null) {
186+
inputsClosed = true
187+
} else {
188+
input = newInput
189+
}
190+
// No output. Returning from the select will go to the top of the loop to do another
191+
// compose pass.
192+
return@onReceiveOrNull null
193+
}
194+
}
195+
196+
// Tick the workflow tree.
197+
rootNode.tick(this) { it }
147198
}
148199
}
149200
} catch (e: Throwable) {
@@ -154,9 +205,9 @@ internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
154205
coroutineContext.cancel(e)
155206
throw e
156207
} finally {
157-
// There's a potential race condition if the producer coroutine is cancelled before it has a chance
158-
// to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we actually
159-
// see this cause problems, I'm not too worried about it.
160-
cancel()
208+
// There's a potential race condition if the producer coroutine is cancelled before it has a
209+
// chance to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we
210+
// actually see this cause problems, I'm not too worried about it.
211+
rootNode.cancel()
161212
}
162213
}

kotlin/workflow-rx2-runtime/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# workflow-rx2-runtime
2+
3+
This module contains adapters to use the Workflow runtime with RxJava2.
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
apply plugin: 'java-library'
17+
apply plugin: 'kotlin'
18+
apply plugin: 'com.vanniktech.maven.publish'
19+
apply plugin: 'org.jetbrains.dokka'
20+
21+
sourceCompatibility = JavaVersion.VERSION_1_7
22+
targetCompatibility = JavaVersion.VERSION_1_7
23+
24+
dokka rootProject.ext.defaultDokkaConfig
25+
26+
dependencies {
27+
compileOnly deps.annotations.intellij
28+
29+
api project(':workflow-runtime')
30+
api deps.kotlin.stdLib.jdk6
31+
api deps.kotlin.coroutines.core
32+
api deps.rxjava2.rxjava2
33+
34+
implementation deps.kotlin.coroutines.rx2
35+
36+
testImplementation project(':workflow-testing')
37+
testImplementation deps.kotlin.test.jdk
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#
2+
# Copyright 2019 Square Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
POM_ARTIFACT_ID=workflow-rx2-runtime
17+
POM_NAME=Workflow RxJava2 Runtime
18+
POM_PACKAGING=jar

0 commit comments

Comments
 (0)