Skip to content

[feat]: add runtime option to skip render if state is inferred to be unchanged #364

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions Workflow/Sources/SubtreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,29 @@ enum EventSource: Equatable {
}

extension WorkflowNode.SubtreeManager {
/// The possible output types that a SubtreeManager can produce.
enum Output {
case update(any WorkflowAction<WorkflowType>, source: EventSource)
case childDidUpdate(WorkflowUpdateDebugInfo?)
/// Indicates that an event produced a `WorkflowAction` to apply to the node.
///
/// - Parameters:
/// - action: The `WorkflowAction` to be applied to the node.
/// - 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.
/// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update.
case update(
any WorkflowAction<WorkflowType>,
source: EventSource,
subtreeInvalidated: Bool
)

/// 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.
///
/// - Parameters:
/// - debugInfo: Optional debug information about the workflow update.
/// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update.
case childDidUpdate(
WorkflowUpdateDebugInfo?,
subtreeInvalidated: Bool
)
}
}

Expand Down Expand Up @@ -334,7 +354,11 @@ extension WorkflowNode.SubtreeManager {

fileprivate final class ReusableSink<Action: WorkflowAction>: AnyReusableSink where Action.WorkflowType == WorkflowType {
func handle(action: Action) {
let output = Output.update(action, source: .external)
let output = Output.update(
action,
source: .external,
subtreeInvalidated: false // initial state
)

if case .pending = eventPipe.validationState {
// Workflow is currently processing an `event`.
Expand Down Expand Up @@ -515,10 +539,14 @@ extension WorkflowNode.SubtreeManager {
let output = if let outputEvent = workflowOutput.outputEvent {
Output.update(
outputMap(outputEvent),
source: .subtree(workflowOutput.debugInfo)
source: .subtree(workflowOutput.debugInfo),
subtreeInvalidated: workflowOutput.subtreeInvalidated
)
} else {
Output.childDidUpdate(workflowOutput.debugInfo)
Output.childDidUpdate(
workflowOutput.debugInfo,
subtreeInvalidated: workflowOutput.subtreeInvalidated
)
}

eventPipe.handle(event: output)
Expand Down
28 changes: 25 additions & 3 deletions Workflow/Sources/WorkflowHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,19 @@ public final class WorkflowHost<WorkflowType: Workflow> {
workflowType: "\(WorkflowType.self)",
kind: .didUpdate(source: .external)
)
}
},
subtreeInvalidated: true // treat as an invalidation
)
handle(output: output)
}

private func handle(output: WorkflowNode<WorkflowType>.Output) {
mutableRendering.value = rootNode.render()
let shouldRender = !shouldSkipRenderForOutput(output)
if shouldRender {
mutableRendering.value = rootNode.render()
}

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

rootNode.enableEvents()
// If we rendered, the event pipes must be re-enabled
if shouldRender {
rootNode.enableEvents()
}
}

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

// MARK: - Conditional Rendering Utilities

extension WorkflowHost {
private func shouldSkipRenderForOutput(
_ output: WorkflowNode<WorkflowType>.Output
) -> Bool {
// We can skip the render pass if:
// 1. The runtime config supports this behavior.
// 2. No subtree invalidation occurred during action processing.
context.runtimeConfig.renderOnlyIfStateChanged
&& !output.subtreeInvalidated
}
}

// MARK: - HostContext

/// A context object to expose certain root-level information to each node
Expand Down
111 changes: 95 additions & 16 deletions Workflow/Sources/WorkflowNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ final class WorkflowNode<WorkflowType: Workflow> {
hostContext.observer
}

lazy var hasVoidState: Bool = WorkflowType.State.self == Void.self

init(
workflow: WorkflowType,
key: String = "",
Expand Down Expand Up @@ -84,35 +86,41 @@ final class WorkflowNode<WorkflowType: Workflow> {
private func handle(subtreeOutput: SubtreeManager.Output) {
let output: Output

// In all cases, propagate subtree invalidation. We should go from
// `false` -> `true` if the action application result indicates
// that a child node's state changed.
switch subtreeOutput {
case .update(let action, let source):
case .update(let action, let source, let subtreeInvalidated):
/// 'Opens' the existential `any WorkflowAction<WorkflowType>` value
/// allowing the underlying conformance to be applied to the Workflow's State
let outputEvent = openAndApply(
let result = applyAction(
action,
isExternal: source == .external
isExternal: source == .external,
subtreeInvalidated: subtreeInvalidated
)

/// Finally, we tell the outside world that our state has changed (including an output event if it exists).
output = Output(
outputEvent: outputEvent,
outputEvent: result.output,
debugInfo: hostContext.ifDebuggerEnabled {
WorkflowUpdateDebugInfo(
workflowType: "\(WorkflowType.self)",
kind: .didUpdate(source: source.toDebugInfoSource())
)
}
},
subtreeInvalidated: subtreeInvalidated || result.stateChanged
)

case .childDidUpdate(let debugInfo):
case .childDidUpdate(let debugInfo, let subtreeInvalidated):
output = Output(
outputEvent: nil,
debugInfo: hostContext.ifDebuggerEnabled {
WorkflowUpdateDebugInfo(
workflowType: "\(WorkflowType.self)",
kind: .childDidUpdate(debugInfo.unwrappedOrErrorDefault)
)
}
},
subtreeInvalidated: subtreeInvalidated
)
}

Expand Down Expand Up @@ -184,20 +192,43 @@ extension WorkflowNode {
struct Output {
var outputEvent: WorkflowType.Output?
var debugInfo: WorkflowUpdateDebugInfo?
/// Indicates whether a node in the subtree of the current node (self-inclusive)
/// should be considered by the runtime to have changed, and thus be invalid
/// from the perspective of needing to be re-rendered.
var subtreeInvalidated: Bool
}
}

// MARK: - Action Application

extension WorkflowNode {
/// Represents the result of applying a `WorkflowAction` to a workflow's state.
struct ActionApplicationResult {
/// An optional output event produced by the action application.
/// This will be propagated up the workflow hierarchy if present.
var output: WorkflowType.Output?

/// Indicates whether the node's state was modified during action application.
/// This is used to determine if the node needs to be re-rendered and to
/// track invalidation through the workflow hierarchy. Note that currently this
/// value does not definitively indicate if the state actually changed, but should
/// be treated as a 'dirty bit' flag – if it's set, the node should be re-rendered.
var stateChanged: Bool
}

/// Applies an appropriate `WorkflowAction` to advance the underlying Workflow `State`
/// - Parameters:
/// - action: The `WorkflowAction` to apply
/// - isExternal: Whether the handled action came from the 'outside world' vs being bubbled up from a child node
/// - Returns: An optional `Output` produced by the action application
private func openAndApply<A: WorkflowAction>(
private func applyAction<A: WorkflowAction>(
_ action: A,
isExternal: Bool
) -> WorkflowType.Output? where A.WorkflowType == WorkflowType {
let output: WorkflowType.Output?
isExternal: Bool,
subtreeInvalidated: Bool
) -> ActionApplicationResult
where A.WorkflowType == WorkflowType
{
let result: ActionApplicationResult

// handle specific observation call if this is the first node
// processing this 'action cascade'
Expand All @@ -215,19 +246,67 @@ extension WorkflowNode {
state: state,
session: session
)
defer { observerCompletion?(state, output) }
defer { observerCompletion?(state, result.output) }

/// Apply the action to the current state
do {
// FIXME: can we avoid instantiating a class here somehow?
let context = ConcreteApplyContext(storage: workflow)
defer { context.invalidate() }

let wrappedContext = ApplyContext.make(implementation: context)
output = action.apply(toState: &state, context: wrappedContext)

let renderOnlyIfStateChanged = hostContext.runtimeConfig.renderOnlyIfStateChanged

// Local helper that applies the action without any extra logic, and
// allows the caller to decide whether the state should be marked as
// having changed.
func performSimpleActionApplication(
markStateAsChanged: Bool
) -> ActionApplicationResult {
ActionApplicationResult(
output: action.apply(toState: &state, context: wrappedContext),
stateChanged: markStateAsChanged
)
}

// Take this path only if no known state has yet been invalidated
// while handling this chain of action applications. We'll handle
// some cases in which we can reasonably infer if state actually
// changed during the action application.
if renderOnlyIfStateChanged {
// Some child state already changed, so just apply the action
// and say our state changed as well.
if subtreeInvalidated {
result = performSimpleActionApplication(markStateAsChanged: true)
} else {
if let equatableState = state as? (any Equatable) {
// If we can recover an Equatable conformance, then
// compare before & after to see if something changed.
func applyEquatableState<EquatableState: Equatable>(
_ initialState: EquatableState
) -> ActionApplicationResult {
// TODO: is there a CoW tax (that matters) here?
let output = action.apply(toState: &state, context: wrappedContext)
let stateChanged = (state as! EquatableState) != initialState
return ActionApplicationResult(
output: output,
stateChanged: stateChanged
)
}
result = applyEquatableState(equatableState)
} else if hasVoidState {
// State is Void, so treat as no change
result = performSimpleActionApplication(markStateAsChanged: false)
} else {
// Otherwise, assume something changed
result = performSimpleActionApplication(markStateAsChanged: true)
}
}
} else {
result = performSimpleActionApplication(markStateAsChanged: true)
}
}

return output
return result
}
}

Expand Down
Loading
Loading