lynnswap/observationbridge
ObservationBridge is an integration layer that provides a consistent API for Swift Observations.
Requirements
- Swift 6.2
- iOS 18+
- macOS 15+
Basic Usage
Synchronous updates (observe)
import ObservationBridge
var cancellables = Set<ObservationHandle>()
model.observe(\.count) { value in
analytics.markCountChanged(value)
}
.store(in: &cancellables)Async updates (observeTask)
import ObservationBridge
var cancellables = Set<ObservationHandle>()
model.observeTask(\.count) { value in
await analytics.trackCount(value)
}
.store(in: &cancellables)Multiple key paths (trigger-only)
let stateChangeHandle = model.observeTask([\.count, \.isEnabled]) {
await analytics.trackStateChanged()
}If you need derived state from multiple key paths, use trigger-only observation and read the owner inside the callback or task.
Configuration
Options
Available options:
.rateLimit(ObservationRateLimit): explicit rate-limit configuration (.debounce(...)/.throttle(...))..legacyBackend(iOS 26.0+/macOS 26.0+): forces legacywithObservationTrackingbackend even on modern OS.ObservationDebouncefields:interval,tolerance(optional),mode(.immediateFirst/.delayedFirst).ObservationThrottlefields:interval,mode(.latest/.earliest).
Rate-limit notes:
debounceandthrottleare mutually exclusive; combining different rate-limit options is a configuration conflict.throttle(mode: .latest)is the default and means: emit the first value immediately, then emit the latest value seen during each interval.throttle(mode: .earliest)emits the first value seen during each interval after the initial immediate emission.- If you need duplicate suppression, implement it explicitly at the call site.
Clock
Deterministic testing
In tests, pass your own Clock implementation to drive debounce or throttle timing manually:
let clock = MyTestClock() // your Clock implementation for tests
let throttle = ObservationThrottle(interval: .milliseconds(250))
let stream = ObservationBridge(
options: [.rateLimit(.throttle(throttle))],
clock: clock
) {
model.count
}
await clock.sleep(untilSuspendedBy: 1) // helper provided by your test clock
clock.advance(by: .milliseconds(250)) // deterministic time progressionAsyncSequence Style
ObservationBridge
import ObservationBridge
let stream = ObservationBridge {
model.count
}
for await value in stream {
print("count = \(value)")
}makeObservationBridgeStream
let stream = makeObservationBridgeStream {
model.count
}
for await value in stream {
print(value)
}Direct Handle Control
Use direct handle retention if you prefer property-based lifetime control:
let countHandle = model.observe(\.count) { value in
print("count = \(value)")
}
// Stop observation when needed.
countHandle.cancel()Behavior Notes
Both APIs:
- use native
Observationson supported OS versions - fall back to legacy
withObservationTrackingon older OS versions - support non-
Sendableobserved values when producer and consumer closures share the same actor isolation - create a fresh observation pipeline for each
ObservationBridgeiterator - require retaining the returned
ObservationHandleto keep observation active - cancel automatically if the observed owner is released
Backend behavior note:
- by default, native
Observationsis used oniOS/macOS 26.0+, and legacywithObservationTrackingis used on older OS versions .legacyBackendforces legacy behavior oniOS/macOS 26.0+- legacy coalesces burst mutations and emits the latest observed value instead of replaying every intermediate mutation
- native uses Swift
Observationstransaction semantics observeTasknever cancels in-flight work; it preserves the next selected output, then coalesces any additional backlog to the latest pending value- non-
Sendablevalues always use the legacy backend, even oniOS/macOS 26.0+ - non-
Sendableobservation preconditions producer/callback isolation equality; mismatch traps at runtime - keep the returned
ObservationHandle(or store it inSet<ObservationHandle>) while observation should continue cancel()does not remove handles from yourSet; remove them explicitly if desired
Migration
v0.7.0
.removeDuplicateshas been removed fromObservationOptions.- multi-keypath projection overloads that accepted
of:have been removed. - multi-keypath observation is intentionally trigger-only now. Producer-side snapshot projection, including cross-actor derived value delivery, is no longer supported.
- If you need duplicate suppression, implement it at the call site.
Before:
let stateStream = ObservationBridge {
model.count
}
let handle = model.observeTask(
[\.count, \.isEnabled],
of: { owner in (owner.count, owner.isEnabled) }
) { state in
await analytics.trackState(state)
}After:
let handle = model.observeTask([\.count, \.isEnabled]) {
await analytics.trackState((model.count, model.isEnabled))
}This is an intentional API reduction. Multi-keypath observers now only tell you that one of the tracked key paths changed; they do not preserve or deliver a producer-side snapshot anymore.
v0.6.0
.debounce(ObservationDebounce)is deprecated; use.rateLimit(.debounce(...))instead.- Inspect
options.rateLimitinstead of relying on the deprecatedoptions.debounceconvenience accessor.
v0.5.0
- Up to
v0.4.x,observe/observeTaskincluded owner-lifetime automatic handle retention. - Starting with
v0.5.0, automatic handle retention is no longer supported. - Retain the returned
ObservationHandleexplicitly (for example, a stored property orSet<ObservationHandle>), or observation will stop when the handle is released.
Package Metadata
Repository: lynnswap/observationbridge
Default branch: main
README: README.md