portico/Sources/Portico/State/DependencyTracker.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) }
}
}