ObservationBridge is an integration layer that provides a consistent API for Swift Observations.
It provides two usage styles:
- owner-bound callbacks:
observe/observeTask AsyncSequencewrappers:ObservationBridge/makeObservationBridgeStream
observe / observeTask return an ObservationHandle.
Retain the handle while observation should stay active.
- Swift 6.2
- iOS 18+
- macOS 15+
import ObservationBridge
var cancellables = Set<ObservationHandle>()
model.observe(\.count) { value in
analytics.markCountChanged(value)
}
.store(in: &cancellables)import ObservationBridge
var cancellables = Set<ObservationHandle>()
model.observeTask(\.count) { value in
await analytics.trackCount(value)
}
.store(in: &cancellables)let stateChangeHandle = model.observeTask([\.count, \.isEnabled]) {
await analytics.trackStateChanged()
}let stateProjectionHandle = model.observeTask(
[\.count, \.isEnabled],
options: [.removeDuplicates],
of: { owner in
(owner.count, owner.isEnabled)
}
) { state in
await analytics.trackState(state)
}Available options:
.removeDuplicates: suppresses consecutive equal values..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.- When
.removeDuplicatesis combined with a rate limit, duplicate suppression is applied to rate-limited outputs.
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 progressionimport ObservationBridge
let stream = ObservationBridge {
model.count
}
for await value in stream {
print("count = \(value)")
}let stream = makeObservationBridgeStream {
model.count
}
for await value in stream {
print(value)
}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()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 - with
.removeDuplicates, coalescing still avoids re-emitting a value that duplicates the currently delivered one - 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
.debounce(ObservationDebounce)is deprecated; use.rateLimit(.debounce(...))instead.- Inspect
options.rateLimitinstead of relying on the deprecatedoptions.debounceconvenience accessor.
- 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.