portico/Sources/Portico/State/ObservationBridge.swift

94 lines
3.8 KiB
Swift

/// Bridges Swift Observation to Portico's targeted-update model.
///
/// Swift's `withObservationTracking(_:onChange:)` has three properties that
/// make it unusable as a drop-in for ``StateBox`` notification, and this file
/// exists to fix all three:
///
/// 1. `onChange` is one-shot. It must be re-armed by re-running the tracked
/// body, which ``DependencyTracker/run()`` already does.
/// 2. `onChange` is a *willSet* hook - measured on Linux Swift 6.3.3, reading
/// the property inside it still yields the old value. Re-evaluating there
/// would write stale text into the widget.
/// 3. `onChange` is `@Sendable` and non-isolated, so it cannot touch main-actor
/// state directly.
///
/// The bridge answers all three by marking the tracker dirty and flushing on a
/// GLib idle source at ``SourcePriority/highIdle`` - after the mutation has
/// landed, before GTK's layout/redraw idle (`G_PRIORITY_HIGH_IDLE + 20`), and
/// coalesced so N mutations in one turn produce one re-evaluation.
@MainActor enum ObservationBridge {
private static var nextID: UInt64 = 1
private static var registered: [UInt64: WeakTracker] = [:]
private static var dirty: [UInt64] = []
private static var flushScheduled = false
/// Number of trackers currently registered with the bridge.
static var registrationCount: Int { registered.count }
/// Weak handle so a torn-down tracker is not kept alive by the registry.
private struct WeakTracker {
weak var tracker: DependencyTracker?
}
/// Registers `tracker` and returns the `Sendable` token that identifies it
/// from inside an `onChange` closure.
static func register(_ tracker: DependencyTracker) -> UInt64 {
let id = nextID
nextID += 1
registered[id] = WeakTracker(tracker: tracker)
return id
}
/// Drops `id` from the registry; called on tracker teardown.
static func unregister(_ id: UInt64) {
registered[id] = nil
dirty.removeAll { $0 == id }
}
/// Queues `id` for re-evaluation on the next idle turn.
static func markDirty(_ id: UInt64) {
guard registered[id] != nil else { return }
if !dirty.contains(id) { dirty.append(id) }
guard !flushScheduled else { return }
flushScheduled = true
Idle(priority: .highIdle) { flush() }
}
/// Re-runs every dirty tracker, which also re-arms its observation.
///
/// Snapshots the queue first: a re-evaluation may itself dirty another
/// tracker, and that one belongs to the next turn, not this one.
private static func flush() {
flushScheduled = false
let batch = dirty
dirty.removeAll()
for id in batch {
registered[id]?.tracker?.reevaluate()
}
}
}
/// Trampoline out of the non-isolated, `@Sendable` `onChange` closure.
///
/// `onChange` runs on the thread that mutated the observable. Off-main
/// mutations therefore hop to the main actor before marking the tracker dirty.
nonisolated func _porticoObservationDidChange(_ id: UInt64) {
if porticoIsMainThread() {
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
} else {
_Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) }
}
}
/// Number of trackers currently registered with the Observation bridge.
@_spi(Portico) @MainActor public func _porticoObservationRegistrationCount() -> Int {
ObservationBridge.registrationCount
}
/// Empty marker captured by a tracker's `onChange` closure.
///
/// Naturally `Sendable` - a final class with no stored state - so it crosses
/// into the `@Sendable` closure without any unchecked escape hatch. Its only
/// job is to report, via its reference count, whether `withObservationTracking`
/// kept the closure alive; see ``DependencyTracker/didArmObservation``.
nonisolated final class ObservationArmToken: Sendable {}