portico/Sources/Portico/State/StateBox.swift

91 lines
3.6 KiB
Swift

/// Mutable state storage shared by ``State`` and ``Binding``.
///
/// Maintains a subscriber list (explicit binding callbacks, held strongly)
/// and a dependent map (``DependencyTracker`` instances, held strongly).
/// ``notify()`` snapshots both before invoking callbacks, making reentrant
/// mutations safe. Top-level notifications reuse their snapshot buffers; a
/// reentrant notification allocates a temporary snapshot because the outer
/// notification owns those buffers.
@_spi(Portico) @MainActor public final class StateBox<Value> {
private var value: Value
private var nextID = 0
private var subscribers: [Int: (Value) -> Void] = [:]
private var dependents: [ObjectIdentifier: DependencyTracker] = [:]
/// Reused iteration buffers for ``notify()``. Cleared while keeping
/// capacity at the end of every top-level notify, so steady-state
/// mutation allocates nothing and no callback or tracker is retained past
/// the notify that snapshotted it.
private var subscriberScratch: ContiguousArray<(Value) -> Void> = []
private var dependentScratch: ContiguousArray<DependencyTracker> = []
/// Whether a top-level ``notify()`` currently owns the scratch buffers.
private var isNotifying = false
@_spi(Portico) public init(_ value: Value) {
self.value = value
}
/// Tracking read registers the current ``DependencyTracker`` (if any)
/// as a dependent, then returns the value.
@_spi(Portico) public func get() -> Value {
if let t = DependencyTracker.current {
let oid = ObjectIdentifier(t)
if dependents[oid] == nil {
dependents[oid] = t
t.addDeregistration { [weak self] in self?.dependents[oid] = nil }
}
}
return value
}
/// Non-tracking read. Does not register a dependency.
@_spi(Portico) public func peek() -> Value { value }
/// Stores a new value and notifies all subscribers and dependents.
@_spi(Portico) public func set(_ newValue: Value) {
value = newValue
notify()
}
/// Registers a callback that fires on every ``set(_:)`` call until the
/// returned token is cancelled.
@_spi(Portico) public func subscribe(
_ onChange: @escaping (Value) -> Void
) -> SubscriptionToken {
let id = nextID
nextID += 1
subscribers[id] = onChange
return SubscriptionToken { [weak self] in
self?.subscribers[id] = nil
}
}
// MARK: - Private
private func notify() {
if subscribers.isEmpty && dependents.isEmpty { return }
let v = value
// A reentrant notify cannot borrow the buffers the outer loop is
// iterating, so it falls back to the allocating snapshot. Nested
// writes still run synchronously to completion, exactly as before.
if isNotifying {
for cb in Array(subscribers.values) { cb(v) }
for t in Array(dependents.values) { t.reevaluate() }
return
}
isNotifying = true
defer {
isNotifying = false
subscriberScratch.removeAll(keepingCapacity: true)
dependentScratch.removeAll(keepingCapacity: true)
}
subscriberScratch.append(contentsOf: subscribers.values)
for cb in subscriberScratch { cb(v) }
// Snapshot dependents after subscribers so subscriber mutations are
// reflected, preserving the existing ordering contract.
dependentScratch.append(contentsOf: dependents.values)
for t in dependentScratch { t.reevaluate() }
}
}