@@ -39,6 +39,8 @@ final class WorkflowNode<WorkflowType: Workflow> {
39
39
hostContext. observer
40
40
}
41
41
42
+ lazy var hasVoidState : Bool = WorkflowType . State. self == Void . self
43
+
42
44
init (
43
45
workflow: WorkflowType ,
44
46
key: String = " " ,
@@ -84,35 +86,41 @@ final class WorkflowNode<WorkflowType: Workflow> {
84
86
private func handle( subtreeOutput: SubtreeManager . Output ) {
85
87
let output : Output
86
88
89
+ // In all cases, propagate subtree invalidation. We should go from
90
+ // `false` -> `true` if the action application result indicates
91
+ // that a child node's state changed.
87
92
switch subtreeOutput {
88
- case . update( let action, let source) :
93
+ case . update( let action, let source, let subtreeInvalidated ) :
89
94
/// 'Opens' the existential `any WorkflowAction<WorkflowType>` value
90
95
/// allowing the underlying conformance to be applied to the Workflow's State
91
- let outputEvent = openAndApply (
96
+ let result = applyAction (
92
97
action,
93
- isExternal: source == . external
98
+ isExternal: source == . external,
99
+ subtreeInvalidated: subtreeInvalidated
94
100
)
95
101
96
102
/// Finally, we tell the outside world that our state has changed (including an output event if it exists).
97
103
output = Output (
98
- outputEvent: outputEvent ,
104
+ outputEvent: result . output ,
99
105
debugInfo: hostContext. ifDebuggerEnabled {
100
106
WorkflowUpdateDebugInfo (
101
107
workflowType: " \( WorkflowType . self) " ,
102
108
kind: . didUpdate( source: source. toDebugInfoSource ( ) )
103
109
)
104
- }
110
+ } ,
111
+ subtreeInvalidated: subtreeInvalidated || result. stateChanged
105
112
)
106
113
107
- case . childDidUpdate( let debugInfo) :
114
+ case . childDidUpdate( let debugInfo, let subtreeInvalidated ) :
108
115
output = Output (
109
116
outputEvent: nil ,
110
117
debugInfo: hostContext. ifDebuggerEnabled {
111
118
WorkflowUpdateDebugInfo (
112
119
workflowType: " \( WorkflowType . self) " ,
113
120
kind: . childDidUpdate( debugInfo. unwrappedOrErrorDefault)
114
121
)
115
- }
122
+ } ,
123
+ subtreeInvalidated: subtreeInvalidated
116
124
)
117
125
}
118
126
@@ -121,8 +129,6 @@ final class WorkflowNode<WorkflowType: Workflow> {
121
129
122
130
/// Internal method that forwards the render call through the underlying `subtreeManager`,
123
131
/// and eventually to the client-specified `Workflow` instance.
124
- /// - Parameter isRootNode: whether or not this is the root node of the tree. Note, this
125
- /// is currently only used as a hint for the logging infrastructure, and is up to callers to correctly specify.
126
132
/// - Returns: A `Rendering` of appropriate type
127
133
func render( ) -> WorkflowType . Rendering {
128
134
WorkflowLogger . logWorkflowStartedRendering ( ref: self )
@@ -184,20 +190,43 @@ extension WorkflowNode {
184
190
struct Output {
185
191
var outputEvent : WorkflowType . Output ?
186
192
var debugInfo : WorkflowUpdateDebugInfo ?
193
+ /// Indicates whether a node in the subtree of the current node (self-inclusive)
194
+ /// should be considered by the runtime to have changed, and thus be invalid
195
+ /// from the perspective of needing to be re-rendered.
196
+ var subtreeInvalidated : Bool
187
197
}
188
198
}
189
199
200
+ // MARK: - Action Application
201
+
190
202
extension WorkflowNode {
203
+ /// Represents the result of applying a `WorkflowAction` to a workflow's state.
204
+ struct ActionApplicationResult {
205
+ /// An optional output event produced by the action application.
206
+ /// This will be propagated up the workflow hierarchy if present.
207
+ var output : WorkflowType . Output ?
208
+
209
+ /// Indicates whether the node's state was modified during action application.
210
+ /// This is used to determine if the node needs to be re-rendered and to
211
+ /// track invalidation through the workflow hierarchy. Note that currently this
212
+ /// value does not definitively indicate if the state actually changed, but should
213
+ /// be treated as a 'dirty bit' flag – if it's set, the node should be re-rendered.
214
+ var stateChanged : Bool
215
+ }
216
+
191
217
/// Applies an appropriate `WorkflowAction` to advance the underlying Workflow `State`
192
218
/// - Parameters:
193
219
/// - action: The `WorkflowAction` to apply
194
220
/// - isExternal: Whether the handled action came from the 'outside world' vs being bubbled up from a child node
195
221
/// - Returns: An optional `Output` produced by the action application
196
- private func openAndApply < A: WorkflowAction > (
222
+ private func applyAction < A: WorkflowAction > (
197
223
_ action: A ,
198
- isExternal: Bool
199
- ) -> WorkflowType . Output ? where A. WorkflowType == WorkflowType {
200
- let output : WorkflowType . Output ?
224
+ isExternal: Bool ,
225
+ subtreeInvalidated: Bool
226
+ ) -> ActionApplicationResult
227
+ where A. WorkflowType == WorkflowType
228
+ {
229
+ let result : ActionApplicationResult
201
230
202
231
// handle specific observation call if this is the first node
203
232
// processing this 'action cascade'
@@ -215,19 +244,67 @@ extension WorkflowNode {
215
244
state: state,
216
245
session: session
217
246
)
218
- defer { observerCompletion ? ( state, output) }
247
+ defer { observerCompletion ? ( state, result . output) }
219
248
220
- /// Apply the action to the current state
221
249
do {
222
250
// FIXME: can we avoid instantiating a class here somehow?
223
251
let context = ConcreteApplyContext ( storage: workflow)
224
252
defer { context. invalidate ( ) }
225
-
226
253
let wrappedContext = ApplyContext . make ( implementation: context)
227
- output = action. apply ( toState: & state, context: wrappedContext)
254
+
255
+ let renderOnlyIfStateChanged = hostContext. runtimeConfig. renderOnlyIfStateChanged
256
+
257
+ // Local helper that applies the action without any extra logic, and
258
+ // allows the caller to decide whether the state should be marked as
259
+ // having changed.
260
+ func performSimpleActionApplication(
261
+ markStateAsChanged: Bool
262
+ ) -> ActionApplicationResult {
263
+ ActionApplicationResult (
264
+ output: action. apply ( toState: & state, context: wrappedContext) ,
265
+ stateChanged: markStateAsChanged
266
+ )
267
+ }
268
+
269
+ // Take this path only if no known state has yet been invalidated
270
+ // while handling this chain of action applications. We'll handle
271
+ // some cases in which we can reasonably infer if state actually
272
+ // changed during the action application.
273
+ if renderOnlyIfStateChanged {
274
+ // Some child state already changed, so just apply the action
275
+ // and say our state changed as well.
276
+ if subtreeInvalidated {
277
+ result = performSimpleActionApplication ( markStateAsChanged: true )
278
+ } else {
279
+ if let equatableState = state as? ( any Equatable ) {
280
+ // If we can recover an Equatable conformance, then
281
+ // compare before & after to see if something changed.
282
+ func applyEquatableState< EquatableState: Equatable > (
283
+ _ initialState: EquatableState
284
+ ) -> ActionApplicationResult {
285
+ // TODO: is there a CoW tax (that matters) here?
286
+ let output = action. apply ( toState: & state, context: wrappedContext)
287
+ let stateChanged = ( state as! EquatableState ) != initialState
288
+ return ActionApplicationResult (
289
+ output: output,
290
+ stateChanged: stateChanged
291
+ )
292
+ }
293
+ result = applyEquatableState ( equatableState)
294
+ } else if hasVoidState {
295
+ // State is Void, so treat as no change
296
+ result = performSimpleActionApplication ( markStateAsChanged: false )
297
+ } else {
298
+ // Otherwise, assume something changed
299
+ result = performSimpleActionApplication ( markStateAsChanged: true )
300
+ }
301
+ }
302
+ } else {
303
+ result = performSimpleActionApplication ( markStateAsChanged: true )
304
+ }
228
305
}
229
306
230
- return output
307
+ return result
231
308
}
232
309
}
233
310
0 commit comments