Skip to content

Commit 6fea2ea

Browse files
WIP: Add the ability to update the input for a WorkflowHost.
Closes #247.
1 parent 40766df commit 6fea2ea

File tree

8 files changed

+142
-86
lines changed

8 files changed

+142
-86
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/workflow-runtime/src/main/java/com/squareup/workflow/WorkflowHost.kt

+9-68
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,8 @@ 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
23-
import com.squareup.workflow.internal.WorkflowNode
24-
import com.squareup.workflow.internal.id
25-
import kotlinx.coroutines.CancellationException
26-
import kotlinx.coroutines.cancel
22+
import com.squareup.workflow.internal.RealWorkflowHost
2723
import kotlinx.coroutines.channels.ReceiveChannel
28-
import kotlinx.coroutines.channels.produce
29-
import kotlinx.coroutines.isActive
30-
import kotlinx.coroutines.selects.select
3124
import org.jetbrains.annotations.TestOnly
3225
import kotlin.coroutines.CoroutineContext
3326
import kotlin.coroutines.EmptyCoroutineContext
@@ -37,7 +30,7 @@ import kotlin.coroutines.EmptyCoroutineContext
3730
*
3831
* Create these by injecting a [Factory] and calling [run][Factory.run].
3932
*/
40-
interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
33+
interface WorkflowHost<in InputT : Any, out OutputT : Any, out RenderingT : Any> {
4134

4235
/**
4336
* Output from a [WorkflowHost]. Emitted from [WorkflowHost.updates] after every compose pass.
@@ -55,6 +48,8 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
5548
*/
5649
val updates: ReceiveChannel<Update<OutputT, RenderingT>>
5750

51+
fun setInput(input: InputT)
52+
5853
/**
5954
* Inject one of these to start root [Workflow]s.
6055
*/
@@ -78,13 +73,14 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
7873
input: InputT,
7974
snapshot: Snapshot? = null,
8075
context: CoroutineContext = EmptyCoroutineContext
81-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, input, snapshot, context)
76+
): WorkflowHost<InputT, OutputT, RenderingT> =
77+
RealWorkflowHost(workflow.asStatefulWorkflow(), baseContext + context, input, snapshot)
8278

8379
fun <OutputT : Any, RenderingT : Any> run(
8480
workflow: Workflow<Unit, OutputT, RenderingT>,
8581
snapshot: Snapshot? = null,
8682
context: CoroutineContext = EmptyCoroutineContext
87-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, Unit, snapshot, context)
83+
): WorkflowHost<Unit, OutputT, RenderingT> = run(workflow, Unit, snapshot, context)
8884

8985
/**
9086
* Creates a [WorkflowHost] that runs [workflow] starting from [initialState].
@@ -99,63 +95,8 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
9995
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
10096
input: InputT,
10197
initialState: StateT
102-
): WorkflowHost<OutputT, RenderingT> {
103-
val workflowId = workflow.id()
104-
return object : WorkflowHost<OutputT, RenderingT> {
105-
val node = WorkflowNode(workflowId, workflow, input, null, baseContext, initialState)
106-
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
107-
node.start(workflow, input)
108-
}
109-
}
110-
111-
internal fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
112-
id: WorkflowId<InputT, OutputT, RenderingT>,
113-
workflow: Workflow<InputT, OutputT, RenderingT>,
114-
input: InputT,
115-
snapshot: Snapshot?,
116-
context: CoroutineContext
117-
): WorkflowHost<OutputT, RenderingT> = object : WorkflowHost<OutputT, RenderingT> {
118-
val node = WorkflowNode(
119-
id = id,
120-
workflow = workflow.asStatefulWorkflow(),
121-
initialInput = input,
122-
snapshot = snapshot,
123-
baseContext = baseContext + context
124-
)
125-
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
126-
node.start(workflow.asStatefulWorkflow(), input)
127-
}
128-
}
129-
}
130-
131-
/**
132-
* Starts the coroutine that runs the coroutine loop.
133-
*/
134-
internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
135-
workflow: StatefulWorkflow<I, *, O, R>,
136-
input: I
137-
): ReceiveChannel<Update<O, R>> = produce(capacity = 0) {
138-
try {
139-
var output: O? = null
140-
while (isActive) {
141-
val rendering = compose(workflow, input)
142-
val snapshot = snapshot(workflow)
143-
send(Update(rendering, snapshot, output))
144-
// Tick _might_ return an output, but if it returns null, it means the state or a child
145-
// probably changed, so we should re-compose/snapshot and emit again.
146-
output = select {
147-
tick(this) { it }
148-
}
98+
): WorkflowHost<InputT, OutputT, RenderingT> {
99+
return RealWorkflowHost(workflow, baseContext, input, null, initialState)
149100
}
150-
} catch (e: Throwable) {
151-
// For some reason the exception gets masked if we don't explicitly pass it to cancel the
152-
// producer coroutine ourselves here.
153-
coroutineContext.cancel(if (e is CancellationException) e else CancellationException(null, e))
154-
throw e
155-
} finally {
156-
// There's a potential race condition if the producer coroutine is cancelled before it has a chance
157-
// to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we actually
158-
// see this cause problems, I'm not too worried about it.
159-
cancel()
160101
}
161102
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
@file:Suppress("EXPERIMENTAL_API_USAGE")
2+
3+
package com.squareup.workflow.internal
4+
5+
import com.squareup.workflow.Snapshot
6+
import com.squareup.workflow.StatefulWorkflow
7+
import com.squareup.workflow.WorkflowHost
8+
import com.squareup.workflow.WorkflowHost.Update
9+
import kotlinx.coroutines.CancellationException
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.cancel
12+
import kotlinx.coroutines.channels.Channel
13+
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
14+
import kotlinx.coroutines.channels.ReceiveChannel
15+
import kotlinx.coroutines.channels.produce
16+
import kotlinx.coroutines.ensureActive
17+
import kotlinx.coroutines.selects.select
18+
import kotlin.coroutines.CoroutineContext
19+
import kotlin.coroutines.coroutineContext
20+
21+
/**
22+
* @param initialState Allows unit tests to start the host from a given state, instead of calling
23+
* [StatefulWorkflow.initialState].
24+
*/
25+
internal class RealWorkflowHost<InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any>(
26+
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
27+
context: CoroutineContext,
28+
initialInput: InputT,
29+
private val initialSnapshot: Snapshot?,
30+
private val initialState: StateT? = null
31+
) : WorkflowHost<InputT, OutputT, RenderingT> {
32+
33+
private val scope = CoroutineScope(context)
34+
private val inputs = Channel<InputT>(capacity = UNLIMITED)
35+
.apply { offer(initialInput) }
36+
37+
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
38+
scope.produce(capacity = 0) {
39+
runWorkflowTree(
40+
workflow = workflow,
41+
onUpdate = ::send
42+
)
43+
}
44+
45+
override fun setInput(input: InputT) {
46+
inputs.offer(input)
47+
}
48+
49+
/**
50+
* Loops forever, or until the coroutine is cancelled, processing the workflow tree and emitting
51+
* updates by calling [onUpdate].
52+
*/
53+
private suspend fun runWorkflowTree(
54+
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
55+
onUpdate: suspend (Update<OutputT, RenderingT>) -> Unit
56+
): Nothing {
57+
var output: OutputT? = null
58+
var input: InputT = inputs.receive()
59+
var inputsClosed = false
60+
val workflowNode = WorkflowNode(
61+
id = workflow.id(),
62+
workflow = workflow,
63+
initialInput = input,
64+
snapshot = initialSnapshot,
65+
baseContext = coroutineContext,
66+
initialState = initialState
67+
)
68+
69+
try {
70+
while (true) {
71+
coroutineContext.ensureActive()
72+
73+
val rendering = workflowNode.compose(workflow, input)
74+
val snapshot = workflowNode.snapshot(workflow)
75+
76+
onUpdate(Update(rendering, snapshot, output))
77+
78+
// Tick _might_ return an output, but if it returns null, it means the state or a child
79+
// probably changed, so we should re-compose/snapshot and emit again.
80+
output = select {
81+
// While the inputs channel is still open, select on it so we can detect new inputs.
82+
if (!inputsClosed) {
83+
@Suppress("EXPERIMENTAL_API_USAGE")
84+
inputs.onReceiveOrNull { newInput ->
85+
if (newInput == null) {
86+
inputsClosed = true
87+
} else {
88+
input = newInput
89+
}
90+
// No output.
91+
return@onReceiveOrNull null
92+
}
93+
}
94+
95+
// Tick the workflow tree.
96+
workflowNode.tick(this) { it }
97+
}
98+
}
99+
} catch (e: Throwable) {
100+
// For some reason the exception gets masked if we don't explicitly pass it to cancel the
101+
// producer coroutine ourselves here.
102+
coroutineContext.cancel(if (e is CancellationException) e else CancellationException(null, e))
103+
throw e
104+
} finally {
105+
// There's a potential race condition if the producer coroutine is cancelled before it has a
106+
// chance to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we
107+
// actually see this cause problems, I'm not too worried about it.
108+
workflowNode.cancel()
109+
}
110+
}
111+
}

kotlin/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTester.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ import kotlin.coroutines.CoroutineContext
4343
* - [hasRendering], [hasOutput], [hasSnapshot]
4444
* - Return `true` if the previous methods won't block.
4545
*/
46-
class WorkflowTester<OutputT : Any, RenderingT : Any> @TestOnly internal constructor(
47-
private val host: WorkflowHost<OutputT, RenderingT>,
46+
class WorkflowTester<InputT : Any, OutputT : Any, RenderingT : Any> @TestOnly internal constructor(
47+
private val host: WorkflowHost<InputT, OutputT, RenderingT>,
4848
context: CoroutineContext
4949
) {
5050

kotlin/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTesting.kt

+7-7
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ fun <T, InputT : Any, OutputT : Any, RenderingT : Any>
4242
input: InputT,
4343
snapshot: Snapshot? = null,
4444
context: CoroutineContext = EmptyCoroutineContext,
45-
block: (WorkflowTester<OutputT, RenderingT>) -> T
45+
block: (WorkflowTester<InputT, OutputT, RenderingT>) -> T
4646
): T = test(block, context) { it.run(this, input, snapshot) }
4747
// @formatter:on
4848

@@ -55,7 +55,7 @@ fun <T, InputT : Any, OutputT : Any, RenderingT : Any>
5555
fun <T, OutputT : Any, RenderingT : Any> Workflow<Unit, OutputT, RenderingT>.testFromStart(
5656
snapshot: Snapshot? = null,
5757
context: CoroutineContext = EmptyCoroutineContext,
58-
block: (WorkflowTester<OutputT, RenderingT>) -> T
58+
block: (WorkflowTester<Unit, OutputT, RenderingT>) -> T
5959
): T = testFromStart(Unit, snapshot, context, block)
6060

6161
/**
@@ -72,7 +72,7 @@ fun <T, InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any>
7272
input: InputT,
7373
initialState: StateT,
7474
context: CoroutineContext = EmptyCoroutineContext,
75-
block: (WorkflowTester<OutputT, RenderingT>) -> T
75+
block: (WorkflowTester<InputT, OutputT, RenderingT>) -> T
7676
): T = test(block, context) { it.runTestFromState(this, input, initialState) }
7777
// @formatter:on
7878

@@ -89,15 +89,15 @@ fun <StateT : Any, OutputT : Any, RenderingT : Any>
8989
StatefulWorkflow<Unit, StateT, OutputT, RenderingT>.testFromState(
9090
initialState: StateT,
9191
context: CoroutineContext = EmptyCoroutineContext,
92-
block: (WorkflowTester<OutputT, RenderingT>) -> Unit
92+
block: (WorkflowTester<Unit, OutputT, RenderingT>) -> Unit
9393
) = testFromState(Unit, initialState, context, block)
9494
// @formatter:on
9595

9696
@UseExperimental(InternalCoroutinesApi::class)
97-
private fun <T, O : Any, R : Any> test(
98-
testBlock: (WorkflowTester<O, R>) -> T,
97+
private fun <T, I : Any, O : Any, R : Any> test(
98+
testBlock: (WorkflowTester<I, O, R>) -> T,
9999
baseContext: CoroutineContext,
100-
starter: (WorkflowHost.Factory) -> WorkflowHost<O, R>
100+
starter: (WorkflowHost.Factory) -> WorkflowHost<I, O, R>
101101
): T {
102102
val context = Dispatchers.Unconfined + baseContext + Job(parent = baseContext[Job])
103103
val host = WorkflowHost.Factory(context)

kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowActivityRunner.kt

+7-5
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import io.reactivex.Observable
2929
* You'll never instantiate one of these yourself. Instead, use
3030
* [FragmentActivity.setContentWorkflow]. See that method for more details.
3131
*/
32-
class WorkflowActivityRunner<OutputT : Any, RenderingT : Any>
33-
internal constructor(private val model: WorkflowViewModel<OutputT, RenderingT>) {
32+
class WorkflowActivityRunner<InputT : Any, OutputT : Any, RenderingT : Any>
33+
internal constructor(private val model: WorkflowViewModel<InputT, OutputT, RenderingT>) {
3434

3535
internal val renderings: Observable<out RenderingT> = model.updates.map { it.rendering }
3636

@@ -43,6 +43,8 @@ internal constructor(private val model: WorkflowViewModel<OutputT, RenderingT>)
4343
val output: Observable<out OutputT> = model.updates.filter { it.output != null }
4444
.map { it.output!! }
4545

46+
fun setInput(input: InputT) = model.setInput(input)
47+
4648
/**
4749
* Returns a [Parcelable] instance of [PickledWorkflow] to be written
4850
* to the bundle passed to [FragmentActivity.onSaveInstanceState].
@@ -108,7 +110,7 @@ fun <InputT : Any, OutputT : Any, RenderingT : Any> FragmentActivity.setContentW
108110
workflow: Workflow<InputT, OutputT, RenderingT>,
109111
initialInput: InputT,
110112
restored: PickledWorkflow?
111-
): WorkflowActivityRunner<OutputT, RenderingT> {
113+
): WorkflowActivityRunner<InputT, OutputT, RenderingT> {
112114
val factory = WorkflowViewModel.Factory(viewRegistry, workflow, initialInput, restored)
113115

114116
// We use an Android lifecycle ViewModel to shield ourselves from configuration changes.
@@ -118,7 +120,7 @@ fun <InputT : Any, OutputT : Any, RenderingT : Any> FragmentActivity.setContentW
118120

119121
@Suppress("UNCHECKED_CAST")
120122
val viewModel = ViewModelProviders.of(this, factory)[WorkflowViewModel::class.java]
121-
as WorkflowViewModel<OutputT, RenderingT>
123+
as WorkflowViewModel<InputT, OutputT, RenderingT>
122124
val runner = WorkflowActivityRunner(viewModel)
123125

124126
val layout = WorkflowLayout(this@setContentWorkflow)
@@ -140,6 +142,6 @@ fun <OutputT : Any, RenderingT : Any> FragmentActivity.setContentWorkflow(
140142
viewRegistry: ViewRegistry,
141143
workflow: Workflow<Unit, OutputT, RenderingT>,
142144
restored: PickledWorkflow?
143-
): WorkflowActivityRunner<OutputT, RenderingT> {
145+
): WorkflowActivityRunner<Unit, OutputT, RenderingT> {
144146
return setContentWorkflow(viewRegistry, workflow, Unit, restored)
145147
}

kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowLayout.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ internal class WorkflowLayout(
4747
* up to date, and may also make recursive calls to [ViewRegistry.getBinding] to make
4848
* children of their own to handle nested renderings.
4949
*/
50-
fun setWorkflowRunner(workflowRunner: WorkflowActivityRunner<*, *>) {
50+
fun setWorkflowRunner(workflowRunner: WorkflowActivityRunner<*, *, *>) {
5151
takeWhileAttached(
5252
workflowRunner.renderings.distinctUntilChanged { rendering -> rendering::class }) {
5353
show(it, workflowRunner.renderings.ofType(it::class.java), workflowRunner.viewRegistry)

kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowViewModel.kt

+4-2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ import kotlinx.coroutines.rx2.asObservable
2929
* [ViewModel], but that would allow accidental calls to [onCleared], which
3030
* would be nasty.
3131
*/
32-
internal class WorkflowViewModel<OutputT : Any, RenderingT : Any>(
32+
internal class WorkflowViewModel<InputT : Any, OutputT : Any, RenderingT : Any>(
3333
val viewRegistry: ViewRegistry,
34-
host: WorkflowHost<OutputT, RenderingT>
34+
private val host: WorkflowHost<InputT, OutputT, RenderingT>
3535
) : ViewModel() {
3636

3737
internal class Factory<InputT : Any, OutputT : Any, RenderingT : Any>(
@@ -61,6 +61,8 @@ internal class WorkflowViewModel<OutputT : Any, RenderingT : Any>(
6161
.replay(1)
6262
.autoConnect(1) { sub = it }
6363

64+
fun setInput(input: InputT) = host.setInput(input)
65+
6466
override fun onCleared() {
6567
// Has the side effect of closing the updates channel, which in turn
6668
// will fire any tear downs registered by the root workflow.

0 commit comments

Comments
 (0)