Skip to content
99 changes: 90 additions & 9 deletions Workflow/Sources/SubtreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,23 @@ extension WorkflowNode {
/// The current array of side-effects
internal private(set) var sideEffectLifetimes: [AnyHashable: SideEffectLifetime] = [:]

init() {}
private let session: WorkflowSession

private let observer: WorkflowObserver?

init(
session: WorkflowSession,
observer: WorkflowObserver? = nil
) {
self.session = session
self.observer = observer
}

/// Performs an update pass using the given closure.
func render<Rendering>(_ actions: (RenderContext<WorkflowType>) -> Rendering) -> Rendering {
func render<Rendering>(
_ actions: (RenderContext<WorkflowType>) -> Rendering,
workflow: WorkflowType
) -> Rendering {
/// Invalidate the previous action handlers.
for eventPipe in eventPipes {
eventPipe.invalidate()
Expand All @@ -47,7 +60,10 @@ extension WorkflowNode {
let context = Context(
previousSinks: previousSinks,
originalChildWorkflows: childWorkflows,
originalSideEffectLifetimes: sideEffectLifetimes
originalSideEffectLifetimes: sideEffectLifetimes,
workflow: workflow,
session: session,
observer: observer
)

let wrapped = RenderContext.make(implementation: context)
Expand Down Expand Up @@ -134,10 +150,17 @@ extension WorkflowNode.SubtreeManager {
private let originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
internal private(set) var usedSideEffectLifetimes: [AnyHashable: SideEffectLifetime]

private let workflow: WorkflowType
private let session: WorkflowSession
private let observer: WorkflowObserver?

internal init(
previousSinks: [ObjectIdentifier: AnyReusableSink],
originalChildWorkflows: [ChildKey: AnyChildWorkflow],
originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime],
workflow: WorkflowType,
session: WorkflowSession,
observer: WorkflowObserver?
) {
self.eventPipes = []

Expand All @@ -148,13 +171,24 @@ extension WorkflowNode.SubtreeManager {

self.originalSideEffectLifetimes = originalSideEffectLifetimes
self.usedSideEffectLifetimes = [:]

self.workflow = workflow
self.session = session
self.observer = observer
}

func render<Child, Action>(workflow: Child, key: String, outputMap: @escaping (Child.Output) -> Action) -> Child.Rendering where Child: Workflow, Action: WorkflowAction, WorkflowType == Action.WorkflowType {
func render<Child, Action>(
workflow: Child,
key: String,
outputMap: @escaping (Child.Output) -> Action
) -> Child.Rendering
where Child: Workflow,
Action: WorkflowAction,
WorkflowType == Action.WorkflowType {
/// A unique key used to identify this child workflow
let childKey = ChildKey(childType: Child.self, key: key)

/// If the key already exists in `used`, than a workflow of the same type has been rendered multiple times
/// If the key already exists in `used`, then a workflow of the same type has been rendered multiple times
/// during this render pass with the same key. This is not allowed.
guard usedChildWorkflows[childKey] == nil else {
fatalError("Child workflows of the same type must be given unique keys. Duplicate workflows of type \(Child.self) were encountered with the key \"\(key)\" in \(WorkflowType.self)")
Expand Down Expand Up @@ -185,7 +219,10 @@ extension WorkflowNode.SubtreeManager {
child = ChildWorkflow<Child>(
workflow: workflow,
outputMap: { AnyWorkflowAction(outputMap($0)) },
eventPipe: eventPipe
eventPipe: eventPipe,
key: key,
parentSession: session,
observer: observer
)
}

Expand All @@ -198,6 +235,21 @@ extension WorkflowNode.SubtreeManager {
func makeSink<Action>(of actionType: Action.Type) -> Sink<Action> where Action: WorkflowAction, WorkflowType == Action.WorkflowType {
let reusableSink = sinkStore.findOrCreate(actionType: Action.self)

// Update the observation info for use when an action is sent
// through the sink we vend to the 'outside world'. This data
// is stored on the `ReusableSink` instance so that any relevant
// references are decremented once the backing node in the tree
// is removed.
reusableSink.observerInfo = observer.map {
ReusableSink.ObserverInfo(
workflow: workflow,
observer: $0,
session: session
)
}

// Use a weak capture to prevent event propagation once the
// node backing this sink is torn down.
let sink = Sink<Action> { [weak reusableSink] action in
WorkflowLogger.logSinkEvent(ref: SignpostRef(), action: action)

Expand Down Expand Up @@ -271,9 +323,26 @@ extension WorkflowNode.SubtreeManager {
}

fileprivate final class ReusableSink<Action: WorkflowAction>: AnyReusableSink where Action.WorkflowType == WorkflowType {
/// Information to support runtime observation when actions are handled
struct ObserverInfo {
var workflow: WorkflowType
var observer: WorkflowObserver
var session: WorkflowSession
}

var observerInfo: ObserverInfo?

func handle(action: Action) {
let output = Output.update(AnyWorkflowAction(action), source: .external)

if let observerInfo = observerInfo {
observerInfo.observer.workflowDidReceiveAction(
action,
workflow: observerInfo.workflow,
session: observerInfo.session
)
}

if case .pending = eventPipe.validationState {
// Workflow is currently processing an `event`.
// Scheduling it to be processed after.
Expand Down Expand Up @@ -389,9 +458,21 @@ extension WorkflowNode.SubtreeManager {
private let node: WorkflowNode<W>
private var outputMap: (W.Output) -> AnyWorkflowAction<WorkflowType>

init(workflow: W, outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>, eventPipe: EventPipe) {
init(
workflow: W,
outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>,
eventPipe: EventPipe,
key: String,
parentSession: WorkflowSession?,
observer: WorkflowObserver?
) {
self.outputMap = outputMap
self.node = WorkflowNode<W>(workflow: workflow)
self.node = WorkflowNode<W>(
workflow: workflow,
key: key,
parentSession: parentSession,
observer: observer
)

super.init(eventPipe: eventPipe)

Expand Down
6 changes: 6 additions & 0 deletions Workflow/Sources/WorkflowAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public protocol WorkflowAction<WorkflowType> {
public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
private let _apply: (inout WorkflowType.State) -> WorkflowType.Output?

/// Underlying type-erased `WorkflowAction` value, if it exists. Will be nil if the
/// action is defined by a closure. Primarily used for testing purposes.
let _wrappedValue: (any WorkflowAction<WorkflowType>)?

/// Creates a type-erased workflow action that wraps the given instance.
///
/// - Parameter base: A workflow action to wrap.
Expand All @@ -46,13 +50,15 @@ public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
return
}
self._apply = { return base.apply(toState: &$0) }
self._wrappedValue = base
}

/// Creates a type-erased workflow action with the given `apply` implementation.
///
/// - Parameter apply: the apply function for the resulting action.
public init(_ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?) {
self._apply = apply
self._wrappedValue = nil
}

public func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? {
Expand Down
21 changes: 18 additions & 3 deletions Workflow/Sources/WorkflowHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public final class WorkflowHost<WorkflowType: Workflow> {

private let (outputEvent, outputEventObserver) = Signal<WorkflowType.Output, Never>.pipe()

private let rootNode: WorkflowNode<WorkflowType>
// @testable
internal let rootNode: WorkflowNode<WorkflowType>

private let mutableRendering: MutableProperty<WorkflowType.Rendering>

Expand All @@ -47,12 +48,26 @@ public final class WorkflowHost<WorkflowType: Workflow> {
/// Initializes a new host with the given workflow at the root.
///
/// - Parameter workflow: The root workflow in the hierarchy
/// - Parameter observers: An optional array of `WorkflowObservers` that will allow runtime introspection for this `WorkflowHost`
/// - Parameter debugger: An optional debugger. If provided, the host will notify the debugger of updates
/// to the workflow hierarchy as state transitions occur.
public init(workflow: WorkflowType, debugger: WorkflowDebugger? = nil) {
public init(
workflow: WorkflowType,
observers: [WorkflowObserver] = [],
debugger: WorkflowDebugger? = nil
) {
self.debugger = debugger

self.rootNode = WorkflowNode(workflow: workflow)
let observers = WorkflowObservation
.sharedObserversInterceptor
.workflowObservers(for: observers)
.chained()

self.rootNode = WorkflowNode(
workflow: workflow,
parentSession: nil,
observer: observers
)

self.mutableRendering = MutableProperty(rootNode.render(isRootNode: true))
self.rendering = Property(mutableRendering)
Expand Down
73 changes: 67 additions & 6 deletions Workflow/Sources/WorkflowNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,48 @@ final class WorkflowNode<WorkflowType: Workflow> {
private var state: WorkflowType.State

/// Holds the current workflow.
private var workflow: WorkflowType
private(set) var workflow: WorkflowType

/// An optional `WorkflowObserver` instance
let observer: WorkflowObserver?

var onOutput: ((Output) -> Void)?

/// Manages the children of this workflow, including diffs during/after render passes.
private let subtreeManager = SubtreeManager()
private let subtreeManager: SubtreeManager

/// 'Session' metadata associated with this node
let session: WorkflowSession

init(workflow: WorkflowType) {
init(
workflow: WorkflowType,
key: String = "",
parentSession: WorkflowSession? = nil,
observer: WorkflowObserver? = nil
) {
/// Get the initial state
self.workflow = workflow
self.observer = observer
self.session = WorkflowSession(
workflow: workflow,
renderKey: key,
parent: parentSession
)
self.subtreeManager = SubtreeManager(
session: session,
observer: observer
)

self.observer?.sessionDidBegin(session)

self.state = workflow.makeInitialState()

self.observer?.workflowDidMakeInitialState(
workflow,
initialState: state,
session: session
)

WorkflowLogger.logWorkflowStarted(ref: self)

subtreeManager.onUpdate = { [weak self] output in
Expand All @@ -40,6 +70,7 @@ final class WorkflowNode<WorkflowType: Workflow> {
}

deinit {
observer?.sessionDidEnd(session)
WorkflowLogger.logWorkflowFinished(ref: self)
}

Expand All @@ -49,6 +80,13 @@ final class WorkflowNode<WorkflowType: Workflow> {

switch subtreeOutput {
case .update(let event, let source):
let actionObserverCompletion = observer?.workflowWillApplyAction(
event,
workflow: workflow,
state: state,
session: session
)

/// Apply the update to the current state
let outputEvent = event.apply(toState: &state)

Expand All @@ -61,6 +99,8 @@ final class WorkflowNode<WorkflowType: Workflow> {
)
)

actionObserverCompletion?(state, outputEvent)

case .childDidUpdate(let debugInfo):
output = Output(
outputEvent: nil,
Expand All @@ -82,17 +122,29 @@ final class WorkflowNode<WorkflowType: Workflow> {
func render(isRootNode: Bool = false) -> WorkflowType.Rendering {
WorkflowLogger.logWorkflowStartedRendering(ref: self, isRootNode: isRootNode)

let renderObserverCompletion = observer?.workflowWillRender(
workflow,
state: state,
session: session
)

let rendering: WorkflowType.Rendering

defer {
renderObserverCompletion?(rendering)

WorkflowLogger.logWorkflowFinishedRendering(ref: self, isRootNode: isRootNode)
}

return subtreeManager.render { context in
rendering = subtreeManager.render({ context in
workflow
.render(
state: state,
context: context
)
}
}, workflow: workflow)

return rendering
}

func enableEvents() {
Expand All @@ -101,8 +153,17 @@ final class WorkflowNode<WorkflowType: Workflow> {

/// Updates the workflow.
func update(workflow: WorkflowType) {
workflow.workflowDidChange(from: self.workflow, state: &state)
let oldWorkflow = self.workflow

workflow.workflowDidChange(from: oldWorkflow, state: &state)
self.workflow = workflow

observer?.workflowDidChange(
from: oldWorkflow,
to: workflow,
state: state,
session: session
)
}

func makeDebugSnapshot() -> WorkflowHierarchyDebugSnapshot {
Expand Down
Loading