lynnswap/observationbridge
Use ObservationBridge to write continuous Observation callbacks with a portable
Requirements
- Swift 6.2
- iOS 18+
- macOS 15+
Portable Continuous Observation
Create an observation with withPortableContinuousObservation(options:apply:). The returned PortableObservationToken keeps the observation alive.
import ObservationBridge
private var observation: PortableObservationToken?
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.
Events
withPortableContinuousObservation runs its .initial pass synchronously when the observation starts. Later passes are controlled by ObservationOptions.
ObservationEvent.kind describes why the callback is running:
.initial: the first pass.willSet: a tracked dependency is about to change.didSet: a tracked dependency changed.deinit: a tracked dependency deinitialized
ObservationOptions 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. .deinit` is delivered on Swift 6.4 and OS 27+.
Do not store ObservationEvent. Save event.kind if later code needs the reason for the pass.
Call PortableObservationToken.cancel() to stop an observation. The token also cancels when it deinitializes.
ObservationEvent.matches(:) reports whether the current pass can be treated as triggered by a mutation of the supplied key path. .initial and .deinit passes match nothing. When trigger details are unavailable, matches(:) returns true so callers do not skip work for a possible mutation.
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.
ObservationScopeand.observe(model)have been removed from the public
API. Use withPortableContinuousObservation(options:apply:) and keep the returned PortableObservationToken alive.
- The callback now matches Swift's
withContinuousObservationshape. Read
observable values directly from the callback body instead of receiving a model argument.
ObservationDeliveryhas been replaced byPortableObservationToken.
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 PortableObservationToken.
- 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: PortableObservationToken?
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: PortableObservationToken?
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.
ObservationOptionsis now a portable event option set. Later event options
follow withContinuousObservation; use `` for initial-only callbacks.
ObservationEventis now noncopyable and borrowed by the callback. Save
event.kind instead of storing the event itself.
ObservationEvent.matches(_:)reports the key paths that triggered a pass.
The explicit tracking: observe overload has been removed: read the needed properties in the callback and filter passes with matches(_:) instead.
Package Metadata
Repository: lynnswap/observationbridge
Default branch: main
README: README.md