Contents

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.

  • ObservationScope and .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 withContinuousObservation shape. Read

observable values directly from the callback body instead of receiving a model argument.

  • ObservationDelivery has been replaced by PortableObservationToken.

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.

  • ObservationRegistration and .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()
}
  • observeTask has 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(_:), and ObservationScope.cancel(id:) have

been removed. Keep and cancel the returned token before rebinding a dynamic observation.

  • ObservationOptions is now a portable event option set. Later event options

follow withContinuousObservation; use `` for initial-only callbacks.

  • ObservationEvent is 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