From 1355990955ee978edf8921b5de148601f13ae82c Mon Sep 17 00:00:00 2001 From: Jamie Quadri Date: Tue, 11 Mar 2025 21:52:41 -0500 Subject: [PATCH] [refactor]: introduce a 'HostContext' for exposing host info through tree --- Workflow/Sources/SubtreeManager.swift | 42 ++++++++++++++---------- Workflow/Sources/WorkflowHost.swift | 37 +++++++++++++++++---- Workflow/Sources/WorkflowNode.swift | 30 ++++++++++------- Workflow/Tests/SubtreeManagerTests.swift | 2 +- Workflow/Tests/TestUtilities.swift | 16 ++++++++- Workflow/Tests/WorkflowNodeTests.swift | 18 ++++++++++ 6 files changed, 108 insertions(+), 37 deletions(-) diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 351e07eb..ce85f008 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -36,14 +36,19 @@ extension WorkflowNode { private let session: WorkflowSession - private let observer: WorkflowObserver? + /// Reference to the context object for the entity hosting the corresponding node. + private let hostContext: HostContext + + private var observer: WorkflowObserver? { + hostContext.observer + } init( session: WorkflowSession, - observer: WorkflowObserver? = nil + hostContext: HostContext ) { self.session = session - self.observer = observer + self.hostContext = hostContext } /// Performs an update pass using the given closure. @@ -60,8 +65,8 @@ extension WorkflowNode { previousSinks: previousSinks, originalChildWorkflows: childWorkflows, originalSideEffectLifetimes: sideEffectLifetimes, - session: session, - observer: observer + hostContext: hostContext, + session: session ) let wrapped = RenderContext.make(implementation: context) @@ -148,18 +153,21 @@ extension WorkflowNode.SubtreeManager { private let originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime] private(set) var usedSideEffectLifetimes: [AnyHashable: SideEffectLifetime] + private let hostContext: HostContext private let session: WorkflowSession - private let observer: WorkflowObserver? + + private var observer: WorkflowObserver? { + hostContext.observer + } init( previousSinks: [ObjectIdentifier: AnyReusableSink], originalChildWorkflows: [ChildKey: AnyChildWorkflow], originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime], - session: WorkflowSession, - observer: WorkflowObserver? + hostContext: HostContext, + session: WorkflowSession ) { self.eventPipes = [] - self.sinkStore = SinkStore(previousSinks: previousSinks) self.originalChildWorkflows = originalChildWorkflows @@ -168,8 +176,8 @@ extension WorkflowNode.SubtreeManager { self.originalSideEffectLifetimes = originalSideEffectLifetimes self.usedSideEffectLifetimes = [:] + self.hostContext = hostContext self.session = session - self.observer = observer } func render( @@ -194,7 +202,7 @@ extension WorkflowNode.SubtreeManager { let eventPipe = EventPipe() eventPipes.append(eventPipe) - /// See if we can + /// See if we can reuse an existing child node for the given key. if let existing = originalChildWorkflows[childKey] { /// Cast the untyped child into a specific typed child. Because our children are keyed by their workflow /// type, this should never fail. @@ -217,8 +225,8 @@ extension WorkflowNode.SubtreeManager { outputMap: { outputMap($0) }, eventPipe: eventPipe, key: key, - parentSession: session, - observer: observer + hostContext: hostContext, + parentSession: session ) } @@ -453,15 +461,15 @@ extension WorkflowNode.SubtreeManager { outputMap: @escaping (W.Output) -> any WorkflowAction, eventPipe: EventPipe, key: String, - parentSession: WorkflowSession?, - observer: WorkflowObserver? + hostContext: HostContext, + parentSession: WorkflowSession? ) { self.outputMap = outputMap self.node = WorkflowNode( workflow: workflow, key: key, - parentSession: parentSession, - observer: observer + hostContext: hostContext, + parentSession: parentSession ) super.init(eventPipe: eventPipe) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 4c734544..40eac5e0 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -32,8 +32,6 @@ public protocol WorkflowDebugger { /// Manages an active workflow hierarchy. public final class WorkflowHost { - private let debugger: WorkflowDebugger? - private let (outputEvent, outputEventObserver) = Signal.pipe() // @testable @@ -45,6 +43,13 @@ public final class WorkflowHost { /// as state transitions occur within the hierarchy. public let rendering: Property + /// Context object to pass down to descendant nodes in the tree. + let context: HostContext + + private var debugger: WorkflowDebugger? { + context.debugger + } + /// Initializes a new host with the given workflow at the root. /// /// - Parameter workflow: The root workflow in the hierarchy @@ -56,17 +61,20 @@ public final class WorkflowHost { observers: [WorkflowObserver] = [], debugger: WorkflowDebugger? = nil ) { - self.debugger = debugger - let observer = WorkflowObservation .sharedObserversInterceptor .workflowObservers(for: observers) .chained() + self.context = HostContext( + observer: observer, + debugger: debugger + ) + self.rootNode = WorkflowNode( workflow: workflow, - parentSession: nil, - observer: observer + hostContext: context, + parentSession: nil ) self.mutableRendering = MutableProperty(rootNode.render()) @@ -115,3 +123,20 @@ public final class WorkflowHost { outputEvent } } + +// MARK: - HostContext + +/// A context object to expose certain root-level information to each node +/// in the Workflow tree. +final class HostContext { + let observer: WorkflowObserver? + let debugger: WorkflowDebugger? + + init( + observer: WorkflowObserver?, + debugger: WorkflowDebugger? + ) { + self.observer = observer + self.debugger = debugger + } +} diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index e540ae5e..c0a4f454 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -16,16 +16,14 @@ /// Manages a running workflow. final class WorkflowNode { - /// Holds the current state of the workflow + /// The current `State` of the node's `Workflow`. private var state: WorkflowType.State - /// Holds the current workflow. + /// Holds the current `Workflow` managed by this node. private var workflow: WorkflowType - /// An optional `WorkflowObserver` instance - let observer: WorkflowObserver? - - var onOutput: ((Output) -> Void)? + /// Reference to the context object for the entity hosting this node. + let hostContext: HostContext /// Manages the children of this workflow, including diffs during/after render passes. private let subtreeManager: SubtreeManager @@ -33,15 +31,23 @@ final class WorkflowNode { /// 'Session' metadata associated with this node let session: WorkflowSession + /// Callback to invoke when a child `Output` is produced. + var onOutput: ((Output) -> Void)? + + /// An optional `WorkflowObserver` instance + var observer: WorkflowObserver? { + hostContext.observer + } + init( workflow: WorkflowType, key: String = "", - parentSession: WorkflowSession? = nil, - observer: WorkflowObserver? = nil + hostContext: HostContext, + parentSession: WorkflowSession? = nil ) { /// Get the initial state self.workflow = workflow - self.observer = observer + self.hostContext = hostContext self.session = WorkflowSession( workflow: workflow, renderKey: key, @@ -49,14 +55,14 @@ final class WorkflowNode { ) self.subtreeManager = SubtreeManager( session: session, - observer: observer + hostContext: hostContext ) - self.observer?.sessionDidBegin(session) + hostContext.observer?.sessionDidBegin(session) self.state = workflow.makeInitialState() - self.observer?.workflowDidMakeInitialState( + observer?.workflowDidMakeInitialState( workflow, initialState: state, session: session diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index 41ecea14..e34e8656 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -353,7 +353,7 @@ extension WorkflowNode.SubtreeManager { fileprivate convenience init() { self.init( session: .testing(), - observer: nil + hostContext: .testing() ) } } diff --git a/Workflow/Tests/TestUtilities.swift b/Workflow/Tests/TestUtilities.swift index d2e40d76..6ae009d6 100644 --- a/Workflow/Tests/TestUtilities.swift +++ b/Workflow/Tests/TestUtilities.swift @@ -15,7 +15,8 @@ */ import Foundation -import Workflow + +@testable import Workflow /// Renders to a model that contains a callback, which in turn sends an output event. struct StateTransitioningWorkflow: Workflow { @@ -55,3 +56,16 @@ struct StateTransitioningWorkflow: Workflow { } } } + +// MARK: - + +extension HostContext { + static func testing( + observer: WorkflowObserver? = nil + ) -> HostContext { + HostContext( + observer: observer, + debugger: nil + ) + } +} diff --git a/Workflow/Tests/WorkflowNodeTests.swift b/Workflow/Tests/WorkflowNodeTests.swift index ad41b42e..c9deb665 100644 --- a/Workflow/Tests/WorkflowNodeTests.swift +++ b/Workflow/Tests/WorkflowNodeTests.swift @@ -400,3 +400,21 @@ private class SessionCollectingObserver: WorkflowObserver { #else extension Never: Equatable {} #endif + +// MARK: - + +extension WorkflowNode { + convenience init( + workflow: WorkflowType, + key: String = "", + parentSession: WorkflowSession? = nil, + observer: WorkflowObserver? = nil + ) { + self.init( + workflow: workflow, + key: key, + hostContext: HostContext.testing(observer: observer), + parentSession: parentSession + ) + } +}