Skip to content

Commit 40bd0e2

Browse files
Merge branch 'main' into johnnewman/feature/dynamic-types
* main: [feat]: add runtime option to skip render if state is inferred to be unchanged (#364) [gardening]: remove doc comment for nonextant parameter (#363)
2 parents a014e95 + d5956af commit 40bd0e2

File tree

5 files changed

+571
-27
lines changed

5 files changed

+571
-27
lines changed

Workflow/Sources/SubtreeManager.swift

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,29 @@ enum EventSource: Equatable {
155155
}
156156

157157
extension WorkflowNode.SubtreeManager {
158+
/// The possible output types that a SubtreeManager can produce.
158159
enum Output {
159-
case update(any WorkflowAction<WorkflowType>, source: EventSource)
160-
case childDidUpdate(WorkflowUpdateDebugInfo?)
160+
/// Indicates that an event produced a `WorkflowAction` to apply to the node.
161+
///
162+
/// - Parameters:
163+
/// - action: The `WorkflowAction` to be applied to the node.
164+
/// - source: The event source that triggered this update. This is primarily used to differentiate between 'external' events and events that originate from the subtree itself.
165+
/// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update.
166+
case update(
167+
any WorkflowAction<WorkflowType>,
168+
source: EventSource,
169+
subtreeInvalidated: Bool
170+
)
171+
172+
/// Indicates that a child workflow within the subtree handled an event and was updated. This informs the parent node about the change and propagates the update 'up' the tree.
173+
///
174+
/// - Parameters:
175+
/// - debugInfo: Optional debug information about the workflow update.
176+
/// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update.
177+
case childDidUpdate(
178+
WorkflowUpdateDebugInfo?,
179+
subtreeInvalidated: Bool
180+
)
161181
}
162182
}
163183

@@ -334,7 +354,11 @@ extension WorkflowNode.SubtreeManager {
334354

335355
fileprivate final class ReusableSink<Action: WorkflowAction>: AnyReusableSink where Action.WorkflowType == WorkflowType {
336356
func handle(action: Action) {
337-
let output = Output.update(action, source: .external)
357+
let output = Output.update(
358+
action,
359+
source: .external,
360+
subtreeInvalidated: false // initial state
361+
)
338362

339363
if case .pending = eventPipe.validationState {
340364
// Workflow is currently processing an `event`.
@@ -515,10 +539,14 @@ extension WorkflowNode.SubtreeManager {
515539
let output = if let outputEvent = workflowOutput.outputEvent {
516540
Output.update(
517541
outputMap(outputEvent),
518-
source: .subtree(workflowOutput.debugInfo)
542+
source: .subtree(workflowOutput.debugInfo),
543+
subtreeInvalidated: workflowOutput.subtreeInvalidated
519544
)
520545
} else {
521-
Output.childDidUpdate(workflowOutput.debugInfo)
546+
Output.childDidUpdate(
547+
workflowOutput.debugInfo,
548+
subtreeInvalidated: workflowOutput.subtreeInvalidated
549+
)
522550
}
523551

524552
eventPipe.handle(event: output)

Workflow/Sources/WorkflowHost.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,19 @@ public final class WorkflowHost<WorkflowType: Workflow> {
101101
workflowType: "\(WorkflowType.self)",
102102
kind: .didUpdate(source: .external)
103103
)
104-
}
104+
},
105+
subtreeInvalidated: true // treat as an invalidation
105106
)
106107
handle(output: output)
107108
}
108109

109110
private func handle(output: WorkflowNode<WorkflowType>.Output) {
110-
mutableRendering.value = rootNode.render()
111+
let shouldRender = !shouldSkipRenderForOutput(output)
112+
if shouldRender {
113+
mutableRendering.value = rootNode.render()
114+
}
111115

116+
// Always emit an output, regardless of whether a render occurs
112117
if let outputEvent = output.outputEvent {
113118
outputEventObserver.send(value: outputEvent)
114119
}
@@ -118,7 +123,10 @@ public final class WorkflowHost<WorkflowType: Workflow> {
118123
updateInfo: output.debugInfo.unwrappedOrErrorDefault
119124
)
120125

121-
rootNode.enableEvents()
126+
// If we rendered, the event pipes must be re-enabled
127+
if shouldRender {
128+
rootNode.enableEvents()
129+
}
122130
}
123131

124132
/// A signal containing output events emitted by the root workflow in the hierarchy.
@@ -127,6 +135,20 @@ public final class WorkflowHost<WorkflowType: Workflow> {
127135
}
128136
}
129137

138+
// MARK: - Conditional Rendering Utilities
139+
140+
extension WorkflowHost {
141+
private func shouldSkipRenderForOutput(
142+
_ output: WorkflowNode<WorkflowType>.Output
143+
) -> Bool {
144+
// We can skip the render pass if:
145+
// 1. The runtime config supports this behavior.
146+
// 2. No subtree invalidation occurred during action processing.
147+
context.runtimeConfig.renderOnlyIfStateChanged
148+
&& !output.subtreeInvalidated
149+
}
150+
}
151+
130152
// MARK: - HostContext
131153

132154
/// A context object to expose certain root-level information to each node

Workflow/Sources/WorkflowNode.swift

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ final class WorkflowNode<WorkflowType: Workflow> {
3939
hostContext.observer
4040
}
4141

42+
lazy var hasVoidState: Bool = WorkflowType.State.self == Void.self
43+
4244
init(
4345
workflow: WorkflowType,
4446
key: String = "",
@@ -84,35 +86,41 @@ final class WorkflowNode<WorkflowType: Workflow> {
8486
private func handle(subtreeOutput: SubtreeManager.Output) {
8587
let output: Output
8688

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.
8792
switch subtreeOutput {
88-
case .update(let action, let source):
93+
case .update(let action, let source, let subtreeInvalidated):
8994
/// 'Opens' the existential `any WorkflowAction<WorkflowType>` value
9095
/// allowing the underlying conformance to be applied to the Workflow's State
91-
let outputEvent = openAndApply(
96+
let result = applyAction(
9297
action,
93-
isExternal: source == .external
98+
isExternal: source == .external,
99+
subtreeInvalidated: subtreeInvalidated
94100
)
95101

96102
/// Finally, we tell the outside world that our state has changed (including an output event if it exists).
97103
output = Output(
98-
outputEvent: outputEvent,
104+
outputEvent: result.output,
99105
debugInfo: hostContext.ifDebuggerEnabled {
100106
WorkflowUpdateDebugInfo(
101107
workflowType: "\(WorkflowType.self)",
102108
kind: .didUpdate(source: source.toDebugInfoSource())
103109
)
104-
}
110+
},
111+
subtreeInvalidated: subtreeInvalidated || result.stateChanged
105112
)
106113

107-
case .childDidUpdate(let debugInfo):
114+
case .childDidUpdate(let debugInfo, let subtreeInvalidated):
108115
output = Output(
109116
outputEvent: nil,
110117
debugInfo: hostContext.ifDebuggerEnabled {
111118
WorkflowUpdateDebugInfo(
112119
workflowType: "\(WorkflowType.self)",
113120
kind: .childDidUpdate(debugInfo.unwrappedOrErrorDefault)
114121
)
115-
}
122+
},
123+
subtreeInvalidated: subtreeInvalidated
116124
)
117125
}
118126

@@ -121,8 +129,6 @@ final class WorkflowNode<WorkflowType: Workflow> {
121129

122130
/// Internal method that forwards the render call through the underlying `subtreeManager`,
123131
/// 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.
126132
/// - Returns: A `Rendering` of appropriate type
127133
func render() -> WorkflowType.Rendering {
128134
WorkflowLogger.logWorkflowStartedRendering(ref: self)
@@ -184,20 +190,43 @@ extension WorkflowNode {
184190
struct Output {
185191
var outputEvent: WorkflowType.Output?
186192
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
187197
}
188198
}
189199

200+
// MARK: - Action Application
201+
190202
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+
191217
/// Applies an appropriate `WorkflowAction` to advance the underlying Workflow `State`
192218
/// - Parameters:
193219
/// - action: The `WorkflowAction` to apply
194220
/// - isExternal: Whether the handled action came from the 'outside world' vs being bubbled up from a child node
195221
/// - Returns: An optional `Output` produced by the action application
196-
private func openAndApply<A: WorkflowAction>(
222+
private func applyAction<A: WorkflowAction>(
197223
_ 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
201230

202231
// handle specific observation call if this is the first node
203232
// processing this 'action cascade'
@@ -215,19 +244,67 @@ extension WorkflowNode {
215244
state: state,
216245
session: session
217246
)
218-
defer { observerCompletion?(state, output) }
247+
defer { observerCompletion?(state, result.output) }
219248

220-
/// Apply the action to the current state
221249
do {
222250
// FIXME: can we avoid instantiating a class here somehow?
223251
let context = ConcreteApplyContext(storage: workflow)
224252
defer { context.invalidate() }
225-
226253
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+
}
228305
}
229306

230-
return output
307+
return result
231308
}
232309
}
233310

0 commit comments

Comments
 (0)