151 lines
6.3 KiB
Swift
151 lines
6.3 KiB
Swift
import Observation
|
|
|
|
/// Tracks which state boxes are read during a reactive body evaluation.
|
|
///
|
|
/// Set ``current`` before evaluation; state-box reads register the
|
|
/// tracker as a dependent so the body is re-evaluated when any
|
|
/// dependency changes. Saves/restores prior `current` so nested
|
|
/// trackers compose correctly.
|
|
///
|
|
/// A tracker also participates in Swift Observation according to its mode. A
|
|
/// `.body` tracker runs its whole body inside `withObservationTracking`; a
|
|
/// `.scoped` tracker tracks only explicit ``observedRegion(_:)`` calls; and a
|
|
/// `.disabled` tracker does not participate in Observation at all. Container
|
|
/// trackers use `.scoped` and mark only their condition-evaluating closures,
|
|
/// because nested `withObservationTracking` attributes descendant reads to the
|
|
/// enclosing region on Linux Swift 6.3.3.
|
|
@_spi(Portico) @MainActor public final class DependencyTracker {
|
|
/// The tracker whose body is currently being evaluated.
|
|
@_spi(Portico) public static var current: DependencyTracker?
|
|
|
|
/// Controls which parts of a tracker's work participate in Swift Observation.
|
|
@_spi(Portico) public enum ObservationMode {
|
|
/// The whole body is tracked. Correct for a body that only computes values.
|
|
case body
|
|
/// Only closures passed to ``observedRegion(_:)`` are tracked. Required
|
|
/// for bodies that mount subtrees, so descendant reads do not dirty the
|
|
/// container tracker.
|
|
case scoped
|
|
/// No Observation participation; only ``StateBox`` edges apply.
|
|
case disabled
|
|
}
|
|
|
|
private let body: () -> Void
|
|
private let mode: ObservationMode
|
|
private var deregistrations: [() -> Void] = []
|
|
|
|
/// Identifies this tracker to the non-isolated Observation callback.
|
|
/// `nil` when the tracker opted out of Observation or was torn down.
|
|
private var observationID: UInt64?
|
|
|
|
/// One reusable marker, captured by every `onChange` closure this tracker
|
|
/// arms. Created on the first armed run and never replaced.
|
|
private var armToken: ObservationArmToken?
|
|
|
|
/// `true` while at least one observation registration made by this tracker
|
|
/// is still live.
|
|
///
|
|
/// `withObservationTracking` releases its `onChange` closure when the body
|
|
/// touched no `@Observable` property. ``armToken`` is captured by that
|
|
/// closure, so the token stops being uniquely referenced exactly when an
|
|
/// observation edge exists.
|
|
@_spi(Portico) public var didArmObservation: Bool {
|
|
guard armToken != nil else { return false }
|
|
return !isKnownUniquelyReferenced(&armToken!)
|
|
}
|
|
|
|
/// - Parameters:
|
|
/// - observation: The tracker's Observation coverage.
|
|
/// - body: The reactive body.
|
|
@_spi(Portico) public init(
|
|
observation: ObservationMode = .body,
|
|
_ body: @escaping () -> Void
|
|
) {
|
|
self.body = body
|
|
self.mode = observation
|
|
self.observationID = nil
|
|
if observation != .disabled {
|
|
self.observationID = ObservationBridge.register(self)
|
|
}
|
|
}
|
|
|
|
/// Returns the reusable marker captured by every `onChange` closure this
|
|
/// tracker arms, creating it on first use.
|
|
private func armedToken() -> ObservationArmToken {
|
|
if let armToken { return armToken }
|
|
let fresh = ObservationArmToken()
|
|
armToken = fresh
|
|
return fresh
|
|
}
|
|
|
|
/// Runs the body with `current` set to `self`, so state reads register
|
|
/// edges. Restores the prior `current` on exit.
|
|
@_spi(Portico) public func run() {
|
|
let previous = DependencyTracker.current
|
|
DependencyTracker.current = self
|
|
if case .body = mode, let id = observationID {
|
|
let token = armedToken()
|
|
withObservationTracking(body) {
|
|
withExtendedLifetime(token) { _porticoObservationDidChange(id) }
|
|
}
|
|
} else {
|
|
body()
|
|
}
|
|
DependencyTracker.current = previous
|
|
}
|
|
|
|
/// Evaluates `region` inside a Swift Observation scope owned by this
|
|
/// tracker. Observable reads inside the region schedule the same coalesced
|
|
/// re-evaluation as a ``StateBox`` write.
|
|
///
|
|
/// In `.body` and `.disabled` modes the closure runs unwrapped. Keep a
|
|
/// scoped region tight: code called by the region is attributed to it, so
|
|
/// arm construction and widget mounting must remain outside the region.
|
|
@_spi(Portico) public func observedRegion<R>(_ region: () -> R) -> R {
|
|
guard case .scoped = mode, let id = observationID else { return region() }
|
|
let token = armedToken()
|
|
return withObservationTracking(region) {
|
|
withExtendedLifetime(token) { _porticoObservationDidChange(id) }
|
|
}
|
|
}
|
|
|
|
/// Re-evaluates the body; called by a state box when its value changes.
|
|
@_spi(Portico) public func reevaluate() { run() }
|
|
|
|
/// `true` once ``run()`` has registered at least one state-box dependency.
|
|
/// Lets a caller drop a tracker whose body reads no state.
|
|
@_spi(Portico) public var hasDependencies: Bool { !deregistrations.isEmpty }
|
|
|
|
/// Runs `body` with dependency tracking suppressed, so state reads inside
|
|
/// it do not register with the tracker that is currently evaluating.
|
|
@_spi(Portico) public static func untracked<Result>(_ body: () -> Result) -> Result {
|
|
let previous = current
|
|
current = nil
|
|
defer { current = previous }
|
|
return body()
|
|
}
|
|
|
|
/// Records how to detach this tracker from a state box it registered with.
|
|
/// Called by ``StateBox/get()`` on first registration.
|
|
@_spi(Portico) public func addDeregistration(_ deregister: @escaping () -> Void) {
|
|
deregistrations.append(deregister)
|
|
}
|
|
|
|
/// Detaches this tracker from every state box and Observation registration,
|
|
/// so it stops re-evaluating. Idempotent.
|
|
@_spi(Portico) public func teardown() {
|
|
for d in deregistrations { d() }
|
|
deregistrations.removeAll()
|
|
if let id = observationID {
|
|
observationID = nil
|
|
ObservationBridge.unregister(id)
|
|
}
|
|
}
|
|
|
|
/// Drops the ObservationBridge registration of a tracker released without
|
|
/// ``teardown()``. `isolated deinit` is required because the bridge is
|
|
/// main-actor isolated.
|
|
isolated deinit {
|
|
if let id = observationID { ObservationBridge.unregister(id) }
|
|
}
|
|
}
|