Consider version of `initialState` that provides a `CoroutineScope` identical to the `WorkflowNode`'s
steve-the-edwards opened this issue · comments
Then for example, a StateFlow operator could be launched from onInitialState under a scope identical to the workflow's scope.
Thinking about this more...
we have had 2 main use cases for a StateFlow :
- So that we could get
.value
out of theStateFlow
ininitialState
to avoid one extra bootstrap re-rendering (for performance reasons). If we are going to do thatstateIn
ininitialState
then it either won't be synchronous (the suspending version) or we already have the correctinitialValue
synchronously (as props or otherwise), in which case we don't really need aStateFlow
frominitialState
onwards. We can just populate the Workflow's state with the initial value and use theflow
form aWorker
inrender()
. - The 2nd case is if we have operators that we want to do on one or more upstream StateFlows. Given any operator has work to do and brings this back to a
flow
, we need tostateIn
after the operator in order to get it back to aStateFlow
to access.value
synchronously ininitialState
. The operator combination is likely a concern of the Workflow usage only, so we want to be able to model the code for it there. It would be better to be able to do the operators and then astateIn
attached to the Workflow's lifecycle itself (thus going back to providing theCoroutineScope
ininitialState
). However, as I think through this now I'm not sure this case doesn't just collapse into the first. If we have all upstreamStateFlow
signals we can.value
them all already ininitialState
and then run the operator/combination logic synchronously (it better be fast) ininitialState
to get a value to populate our state without extra re-renders. Then we're happy enough if the operators result in aflow
to collect from in aWorker
.
So now I've convinced myself that I don't see a case where we need CoroutineScope
in initialState
to accomplish something we couldn't before. It might be a useful convenience, but I need to think about whether or not we want to enable that convenience vs. encouraging doing the above steps. 🤔
Case one is less interesting now that #992 has landed, but this still seems like it would solve a lot of other problems.
still seems like it would solve a lot of other problems.
In terms of discoverability and having good tools 'at hand' you mean?
I feel like I've ruled out what I thought before were the main use cases in terms of being blocked without this.
You have a much clearer picture of the situation than I do. Let's definitely not build this until we have real people with real use cases in hand.
I have a case where I need a CoroutineScope that lasts for the duration of a workflow. I need a scope and object from a callback in order to create a new state. With the current implementation it's going to look like something like this on the render function
context.runningSideEffect("scope") {
val scope = this
context.actionSink.send(action {
val newState = state.result?.let { NewState(NewObject(scope, it))}
val oldState = state.copy(scope = scope)
state = newState ?: oldState
})
// suspend forever
}
return Rendering(
onObjectCreated = context.eventHandler {
val newState = state.scope?.let { NewState(NewObject(it, result))}
val oldState = state.copy(result = result)
state = newState ?: oldState
}
)
This creates a funky race condition where I have to keep track of two callbacks and having a CoroutineScope
available for the workflow would be very helpful.
From an offline conversation with @steve-the-edwards:
public abstract class SessionWorkflow<
SessionT,
in PropsT,
StateT,
out OutputT,
out RenderingT
> : Workflow<PropsT, OutputT, RenderingT>, IdCacheable {
public abstract fun onSessionStarted(scope: CoroutineScope): SessionT
public abstract fun initialState(
session: SessionT,
props: PropsT,
snapshot: Snapshot?
): StateT
public abstract fun render(
session: SessionT,
renderProps: PropsT,
renderState: StateT,
context: RenderContext
): RenderingT
public abstract class StatefulWorkflow<
in PropsT,
StateT,
out OutputT,
out RenderingT
> : SessionWorkflow<Unit, PropsT, OutputT, RenderingT>, IdCacheable
public final fun initialState(
session: SessionT,
props: PropsT,
snapshot: Snapshot?
): StateT = initialState(props, snapshot)
public abstract final initialState(
props: PropsT,
snapshot: Snapshot
): StateT
One big idea here is that SessionT
could be a Dagger Component, built from Dagger modules that could include providers for a StateFlow
whose lifetime needs to match that of a particular WorkflowNode
. Any child workflow instantiated from that Component would be able get at that StateFlow
, or, say, a repository that wraps it, via constructor injection. Or those children could constructor inject child workflows that in turn can inject that StateFlow
…
I like the idea of SessionWorkflow
as a separate type because I think that 99% of Workflow implementations should not be thinking about SessionT
directly. It's useful for high level lifecycle needs -- the LoggedInWorkflow, the SettingsAppletWorkflow. It's just noise for the average FooScreen leaf workflow.
So now the next question is: do we really need SessionT
for this pattern? Or could LoggedInWorkflow
simply manage its Dagger component as part of its StateT
? I bet it could, and in fact we may have some apps doing exactly that already.
So did I just talk myself back into the original plan of simply adding a CoroutineScope
param to initialState()
, and advising folks to take advantage of this by adding a Dagger Component to StateT
? Instance equality would prevent them from blowing up any optimizations.
Note that StateFlowImpl
appears to use default (instance) equality.
So did I just talk myself back into the original plan of simply adding a CoroutineScope param to initialState()
Perhaps, because without a notion of SessionT
or WorkflowLocal
we wouldn't be able to pass any computed StateFlow
from onSessionStarted
through to the StateT
so it would need to be in initialState