91 lines
3.6 KiB
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() }
|
|
}
|
|
}
|
|
|