lynnswap/observationbridge
Use ObservationBridge to write continuous Observation callbacks with a portable
Requirements
- Swift 6.3
- iOS 18+
- Mac Catalyst 18+
- macOS 15+
- tvOS 18+
- watchOS 11+
- visionOS 2+
Portable Continuous Observation
Create an observation with withPortableContinuousObservation(options:apply:). The callback inherits the caller's actor context like Swift's native withContinuousObservation. The returned PortableObservationTracking.Token keeps the observation alive.
import ObservationBridge
private var observation: PortableObservationTracking.Token?
func bindModel() {
observation = withPortableContinuousObservation { [weak self] event in
guard let self else { return }
titleLabel.text = model.title
countLabel.text = "\(model.count)"
saveButton.isEnabled = model.canSave
// matches only filters the current pass. Read rows outside the branch
// so row changes continue to trigger future passes.
_ = model.rows
if event.kind == .initial || event.matches(\Model.rows) {
applySnapshot(
model.rows,
animatingDifferences: event.kind != .initial
)
}
}
}
deinit {
observation?.cancel()
}Read the observable values that should keep triggering the callback on every pass. Use matches(_:) to decide whether to perform additional work for a changed key path, not as the only guard for correctness.
Events
withPortableContinuousObservation intentionally differs from Swift's native withContinuousObservation in one place: it runs its .initial pass synchronously when the observation starts. That pass is still the first tracking pass. Observable values read during .initial become the dependencies that allow later .willSet and .didSet passes to fire.
If the OS 27+ liveness fallback is selected because exact Observation runtime SPI is unavailable, .initial follows native timing and may run after the token is returned.
For the native behavior used as the compatibility reference, see Continuous Observation Compatibility Investigation.
Do not return from .initial before reading the values you want to keep tracking:
let token = withPortableContinuousObservation { event in
let title = model.title
let rows = model.rows
guard event.kind != .initial else {
return
}
titleLabel.text = title
if event.matches(\Model.rows) {
applySnapshot(rows)
}
}Later passes are controlled by PortableObservationTracking.Options.
PortableObservationTracking.Event.kind describes why the callback is running:
.initial: the first tracking pass, delivered synchronously by ObservationBridge.willSet: a tracked dependency is about to change.didSet: a tracked dependency changed
PortableObservationTracking.Options controls which later events are delivered. The default is .didSet:
let didSetObservation = withPortableContinuousObservation(options: .didSet) { event in
render(model)
}
let initialOnlyObservation = withPortableContinuousObservation(options: []) { event in
renderOnce(model)
}` delivers only .initial. .didSet and .willSet are available on all supported versions. When both .willSet and .didSet are requested, ObservationBridge follows native continuous observation cadence and delivers one .didSet` pass for a normal mutation.
Do not store PortableObservationTracking.Event. Save event.kind if later code needs the reason for the pass.
Call PortableObservationTracking.Token.cancel() to stop an observation. The token also cancels when it deinitializes.
PortableObservationTracking.Event.matches(_:) filters the current pass by key path on the exact runtime path. In the OS 27+ liveness fallback, mutation matching is conservative and may match unrelated key paths so updates keep flowing. Treat it as a work filter, not a dependency declaration.
Testing
Use values in tests to record a sample after each observation callback finishes.
struct RenderedState: Sendable, Equatable {
var title: String?
var canSave: Bool
}
let token = withPortableContinuousObservation { _ in
titleLabel.text = model.title
saveButton.isEnabled = model.canSave
}
let rendered = await token.values {
RenderedState(
title: titleLabel.text,
canSave: saveButton.isEnabled
)
}
model.title = "Draft"
model.canSave = true
#expect(await rendered.waitUntilValue(
RenderedState(title: "Draft", canSave: true)
))Sample small Sendable values that describe rendered output, such as label text, enabled state, selected identifiers, row counts, accessibility values, or presentation state.
values { ... } returns an ObservedValues<Value> recorder. It exposes latestValue, snapshot(), waitUntilValue(:timeout:), waitUntil(timeout::), cancel(), and isActive. The timeout arguments are test guards only; they do not change observation delivery.
Migration
Use the notes for the version you are upgrading to.
v0.12.0
These notes apply when upgrading from v0.11.x or earlier to v0.12.0.
ObservationOptionshas been renamed to
PortableObservationTracking.Options.
ObservationEventhas been renamed toPortableObservationTracking.Event.PortableObservationTokenhas been renamed to
PortableObservationTracking.Token.
ObservationScopeand.observe(model)have been removed from the public
API. Use withPortableContinuousObservation(options:apply:) and keep the returned PortableObservationTracking.Token alive.
- The callback now matches Swift's
withContinuousObservationshape. Read
observable values directly from the callback body instead of receiving a model argument.
- Explicit actor override is not part of the public API. Call
withPortableContinuousObservation from the actor context that should own the callback.
ObservationDeliveryhas been replaced byPortableObservationTracking.Token.
Attach test samplers with token.values { ... }.
let token = withPortableContinuousObservation { event in
titleLabel.text = model.title
let rows = model.rows
if event.kind == .initial || event.matches(\Model.rows) {
applySnapshot(rows)
}
}v0.9.0
These notes apply when upgrading from v0.8.x or earlier to v0.9.0.
- Start observations with
withPortableContinuousObservation. Replace
model.observe(...).store(in: observations) with a retained PortableObservationTracking.Token.
- Read observed values inside the callback instead of passing key paths to
observe.
ObservationRegistrationand.store(in:)have been removed without a
compatibility shim.
model.observe(\.count) { value in
countLabel.text = "\(value)"
}
.store(in: observations)After:
private var countObservation: PortableObservationTracking.Token?
func bindCount() {
countObservation = withPortableContinuousObservation { _ in
countLabel.text = "\(model.count)"
}
}
deinit {
countObservation?.cancel()
}observeTaskhas been removed without a compatibility shim. For async work,
start a Task from the observation callback after copying the values you need. Keep any ordering, cancellation, backpressure, debounce, or throttle policy in the owner that starts that task.
private var countObservation: PortableObservationTracking.Token?
func bindCountTracking() {
countObservation = withPortableContinuousObservation { _ in
let count = model.count
Task {
await analytics.trackCount(count)
}
}
}
deinit {
countObservation?.cancel()
}id:,ObservationScope.update(_:), andObservationScope.cancel(id:)have
been removed. Keep and cancel the returned token before rebinding a dynamic observation.
PortableObservationTracking.Optionsis now a portable event option set. Later event options
follow withContinuousObservation; use `` for initial-only callbacks.
PortableObservationTracking.Eventis now noncopyable and borrowed by the callback. Save
event.kind instead of storing the event itself.
PortableObservationTracking.Event.matches(_:)filters the current pass by
trigger key path when trigger details are available. The explicit tracking: observe overload has been removed: read the needed properties in the callback and use matches(_:) only to gate optional extra work for that pass.
Package Metadata
Repository: lynnswap/observationbridge
Default branch: main
README: README.md