← All lessonsGuide · @Observable, in full
Guide

@Observable, in full

How one plain read in a body registers the view to be told when the property changes — the list on the thread, observableObject's registrar, and the closure that holds the view's node — with a model of the whole path that compiles and runs

In lesson 16 you wrote an observable object's registrar by hand, watched withObservationTracking tell you when a property you had read was about to change, and let @Observable write the same code for you. One question was left open, and it is the one everybody asks: a body only reads the property — how does that read sign the view up? This guide answers it with one drawing, and then follows the path in a model small enough to read, written with the names Apple's code uses. The model compiles and runs; what it prints is on this page.

1 · from lesson 16

What an observable is

An observable is an object whose properties can be watched: when one of them changes, whoever read it can be told. In SwiftUI that is how a screen stays current. A view's body reads properties of your model object, and when one of those properties changes, that view is drawn again — only that view, and only for the properties it read. Since iOS 17 the way to make one is the @Observable macro on a final class, with the properties left as plain stored properties:

An observable object, and a view that reads it
import SwiftUI

@Observable
final class Model {
    var property = 0
    var otherProperty = 0
}

struct FirstView: View {
    let observableObject: Model

    var body: some View {
        Text("\(observableObject.property)")
    }
}

Nothing else is needed: no protocol to adopt by hand, no @Published, and nothing on the view's side when the view only reads the object — FirstView takes it as a plain property. A view that makes and owns the object keeps it in @State instead, and one that needs a Binding into it uses @Bindable (lesson 16 and lesson 17). The view reads observableObject.property as it would read any property, and yet it is told when property changes, and left alone when otherProperty does.

The macro does its work while your program is compiled: it adds observableObject's registrar to the class and rewrites each stored property into a getter and a setter with one call each. Lesson 16 builds that by hand, and the section on your class, below, shows the shape. That one call in the getter is where the question starts.

2 · from lesson 16

How does reading a property register the view to be told when it changes?

Nothing in a body registers anything. Text("\(observableObject.property)") is a plain read, and the getter the macro wrote for property is a plain getter with one extra line in it. The registration happens around the read, in three places: a list SwiftUI keeps on the thread while a body runs; observableObject's registrar, which that one extra line writes into the list; and the closure SwiftUI gives observableObject's registrar when the body is done, holding the number of the view's node. When the property changes, observableObject's registrar runs that closure, and the node is invalidated. Here is the whole path, in order:

SWIFTUI running the view’s node, #3176, and keeping the thread’s access list THE VIEW’S BODY view.body THE OBSERVABLE OBJECT observableObject’s registrar lives here observableObject currentAccessList global to the thread; nothing owns it [ ] · empty observableObject · \.property in the list now START 1 · visit the node, and get its number: #3176 2 · set a fresh access list 3 · execute the body reads observableObject.property the getter runs 4 · writes observableObject’s registrar and the key path into the list returns the value the body ends: returns the view 5 · gives each observableObject’s registrar a closure to call back when the property changes, holding the node number of the current view, #3176 _$observationRegistrar: \.property → { node #3176 } 6 · put the thread’s previous list back what it held before then the next view’s node, #3228, runs the same sequence, filing { node #3228 } · later, when property changes: the closure holding #3176 runs: AGGraphInvalidateValue(#3176) the graph visits node #3176 again: the sequence starts over
One read of observableObject.property inside a body, from the graph visiting the view's node to the closure that invalidates it. The node numbers are the ones a trace of a real window gave for two views.

The six steps, in words:

  1. Visit the view's node, and get its number.

  2. Set a fresh access list on the thread.

  3. Execute the body.

  4. Each observableObject it reads writes observableObject's registrar and the key path into the list.

  5. Give each observableObject's registrar in the list a closure to call back when its property changes, holding the current view's node number.

  6. Put the thread's previous list back.

Three things live in three places. The list lives in a slot on the thread — global to the thread, owned by no view or object — which is how a getter deep inside your model finds it: it looks at the thread. observableObject's registrar lives on observableObject; the macro adds it as _$observationRegistrar. And the closures live in observableObject's registrar, one per node that read the object, each holding the number of the node to tell. Nothing holds the view: the list holds the inner object of observableObject's registrar, and the closure holds a number.

3 · from lesson 16

The short version

Here is what SwiftUI does around one view's body, cut down as far as it goes — a function from the model at the end of this guide, which runs. _ThreadLocal.currentAccessList is the slot on the thread, _installTracking the filing of the closures, and AGGraphGetCurrentAttribute and AGGraphInvalidateValue the two calls into the graph:

The model — SwiftUI, running one view's node, with nothing generic
/// The short version: the same steps for one view, with nothing generic.
@MainActor
func runFirstViewsBody(_ view: FirstView) -> Void {
    // 1. Visit the node: get its number
    let node = AGGraphGetCurrentAttribute()

    // 2. A fresh access list, in the thread's slot
    let previous = _ThreadLocal.currentAccessList
    _ThreadLocal.currentAccessList = ObservationTracking._AccessList()

    // 3. The body runs, and 4. each getter it reads writes into that list
    let body = view.body

    // 5. Each observableObject's registrar in the list gets a closure holding this node
    let list = _ThreadLocal.currentAccessList!
    ObservationTracking._installTracking(list) {
        AGGraphInvalidateValue(node)
    }

    // 6. The thread's previous list goes back
    _ThreadLocal.currentAccessList = previous
    _ = body
}

And here is the property, as @Observable expands it (lesson 16, step 6, shows the real expansion in Xcode). access is the write into the list at step 4; withMutation is what later runs the closure from step 5:

The model — the class, as @Observable expands it
import SwiftUI
import Observation

// MARK: - Your class, as @Observable expands it

final class Model: Observable {
    private let _$observationRegistrar: ObservationRegistrar = ObservationRegistrar()
    private var _property: Int = 0
    private var _otherProperty: Int = 0

    var property: Int {
        get {
            // Step 4 lands here: adds observableObject's registrar and the key path to the thread's list
            _$observationRegistrar.access(self, keyPath: \Model.property)
            return _property
        }
        set {
            // A later change: runs the closures waiting on property, then stores the value
            _$observationRegistrar.withMutation(of: self, keyPath: \Model.property) {
                _property = newValue
            }
        }
    }

    var otherProperty: Int {
        get {
            _$observationRegistrar.access(self, keyPath: \Model.otherProperty)
            return _otherProperty
        }
        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \Model.otherProperty) {
                _otherProperty = newValue
            }
        }
    }
}

The rest of this guide is the same path in full, with Apple's name for each part.

4 · from lesson 16

The list: what the getters write into

One _AccessList holds everything a single body read, one Entry for each observable object's registrar. Its key is an ObjectIdentifier: it identifies the object inside observableObject's registrar, so a body that reads two properties of one observableObject makes one entry with two key paths. _installTracking is the loop that runs when the body is done: it gives each observableObject's registrar in the list a closure to call.

The model — the list, and the slot on the thread that holds it
import SwiftUI
import Observation

// MARK: - The list

struct ObservationTracking {
    struct Entry {
        // How this entry reaches the table of closures in one observableObject's registrar.
        let context: ObservationRegistrar.Context
        var properties: Set<AnyKeyPath>
    }

    struct _AccessList {
        // ObjectIdentifier uniquely identifies one class instance while it is alive.
        // Here that instance is a Context.State, the object inside one observableObject's registrar, so
        // each observable object the body reads gets one Entry. In Apple's code the
        // instance is a LockedBuffer.
        var entries: [ObjectIdentifier: Entry] = [:]

        mutating func addAccess(keyPath: AnyKeyPath, context: ObservationRegistrar.Context) -> Void {
            let empty: Entry = Entry(context: context, properties: [])
            entries[context.id, default: empty].properties.insert(keyPath)
        }
    }

    static func _installTracking(_ list: _AccessList, onChange: @escaping @Sendable () -> Void) -> Void {
        for entry: Entry in list.entries.values {
            entry.context.registerTracking(for: entry.properties) { (changed: AnyKeyPath) -> Void in
                onChange()
            }
        }
    }
}

enum _ThreadLocal {
    static var currentAccessList: ObservationTracking._AccessList? = nil
}
5 · from lesson 16

observableObject's registrar: where the closures wait

access is what every getter calls. It writes to the list only if one is open, so the same getter does nothing in a Button action. withMutation is what every setter calls: it runs the closures waiting on that property, then stores the value. A Context is the working part of observableObject's registrar, a small struct holding a reference to State, the table of closures — copies of it share one table, so an entry in the list reaches the table without keeping observableObject alive.

The model — observableObject's registrar
import SwiftUI
import Observation

// MARK: - observableObject's registrar

struct ObservationRegistrar {
    // A Context is the working part of observableObject's registrar: a small struct holding
    // a reference to State, the table of closures. Copies of it all share that one table, so
    // an Entry in the list can keep a Context and reach the table without keeping observableObject.
    struct Context {
        final class State {
            var closures: [AnyKeyPath: [@Sendable (AnyKeyPath) -> Void]] = [:]
        }

        let state: State = State()

        // Identifies the State instance of observableObject's registrar, not observableObject itself.
        var id: ObjectIdentifier {
            ObjectIdentifier(state)
        }

        func registerTracking(
            for properties: Set<AnyKeyPath>,
            willSet observer: @escaping @Sendable (AnyKeyPath) -> Void
        ) -> Void {
            for property: AnyKeyPath in properties {
                state.closures[property, default: []].append(observer)
            }
        }

        func willSet(keyPath: AnyKeyPath) -> Void {
            let waiting: [@Sendable (AnyKeyPath) -> Void] =
                state.closures.removeValue(forKey: keyPath) ?? []
            for closure: @Sendable (AnyKeyPath) -> Void in waiting {
                closure(keyPath)
            }
        }
    }

    private final class Extent {
        let context: Context = Context()
    }

    private let extent: Extent = Extent()

    var context: Context {
        extent.context
    }

    func access<Subject: Observable, Member>(
        _ subject: Subject,
        keyPath: KeyPath<Subject, Member>
    ) -> Void {
        _ThreadLocal.currentAccessList?.addAccess(keyPath: keyPath, context: context)
    }

    func withMutation<Subject: Observable, Member, T>(
        of subject: Subject,
        keyPath: KeyPath<Subject, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        context.willSet(keyPath: keyPath)
        return try mutation()
    }
}
6 · from lesson 16

SwiftUI, running one view's node

updateValue gets the node's number before the body runs, opens a list, runs the body, and takes the list back. invalidate captures the number in the closure it files, which is how a change finds its way back to this view: the closure calls AGGraphInvalidateValue on that node, and the graph runs the body again. Only then is the thread's previous list put back.

The model — the part SwiftUI does
import SwiftUI
import Observation

// MARK: - SwiftUI

enum ObservationCenter {
    static func invalidate(
        _ node: AnyAttribute,
        onChangeIn list: ObservationTracking._AccessList
    ) -> Void {
        ObservationTracking._installTracking(list) { () -> Void in
            AGGraphInvalidateValue(node)
        }
    }
}

/// StaticBody.updateValue, for the node that runs this view's body.
@MainActor
func updateValue<V: View>(of view: V) -> Void {
    let node: AnyAttribute = AGGraphGetCurrentAttribute()

    let previous: ObservationTracking._AccessList? = _ThreadLocal.currentAccessList
    _ThreadLocal.currentAccessList = ObservationTracking._AccessList()

    let body: V.Body = view.body

    let list: ObservationTracking._AccessList = _ThreadLocal.currentAccessList!
    ObservationCenter.invalidate(node, onChangeIn: list)

    _ThreadLocal.currentAccessList = previous
    _ = body
}

The views it runs are two plain views, reading one property each:

The model — two views, each reading one property
import SwiftUI
import Observation

// MARK: - Two views, each reading one property

struct FirstView: View {
    let observableObject: Model

    var body: some View {
        Text("\(observableObject.property)")
    }
}

struct NextView: View {
    let observableObject: Model

    var body: some View {
        Text("\(observableObject.otherProperty)")
    }
}
7 · from lesson 16

Run it

The two calls into AttributeGraph are stand-ins, so the model can run on its own: one hands back whichever node number the run set, the other prints what it is told.

The model — stand-ins for the two calls into AttributeGraph
import SwiftUI
import Observation

// MARK: - Stand-ins for the two calls into AttributeGraph

struct AnyAttribute {
    let rawValue: UInt32
}

nonisolated(unsafe) var runningNode: AnyAttribute = AnyAttribute(rawValue: 0)

func AGGraphGetCurrentAttribute() -> AnyAttribute {
    runningNode
}

func AGGraphInvalidateValue(_ node: AnyAttribute) -> Void {
    print("  AGGraphInvalidateValue(#\(node.rawValue))")
}

The run gives the first view node 3176 and the next view node 3228, the numbers from the trace, runs each body once, then changes each property:

The model — the run
let observableObject = Model()

print("the bodies run: FirstView as node #3176, NextView as node #3228")
runningNode = AnyAttribute(rawValue: 3176)
updateValue(of: FirstView(observableObject: observableObject))
runningNode = AnyAttribute(rawValue: 3228)
updateValue(of: NextView(observableObject: observableObject))

print("observableObject.property += 1")
observableObject.property += 1

print("observableObject.otherProperty += 1")
observableObject.otherProperty += 1

print("observableObject.property += 1 again, before FirstView's body has run again: nothing")
observableObject.property += 1

print("FirstView's body runs again as node #3176, then observableObject.property += 1")
runningNode = AnyAttribute(rawValue: 3176)
updateValue(of: FirstView(observableObject: observableObject))
observableObject.property += 1

print("a read outside any body, then observableObject.otherProperty += 1: nothing")
_ = observableObject.otherProperty
observableObject.otherProperty += 1

print("the short version: FirstView's body runs as node #3176, then property changes")
let another = Model()
runningNode = AnyAttribute(rawValue: 3176)
runFirstViewsBody(FirstView(observableObject: another))
another.property += 1

It prints

Console
the bodies run: FirstView as node #3176, NextView as node #3228
observableObject.property += 1
  AGGraphInvalidateValue(#3176)
observableObject.otherProperty += 1
  AGGraphInvalidateValue(#3228)
observableObject.property += 1 again, before FirstView's body has run again: nothing
FirstView's body runs again as node #3176, then observableObject.property += 1
  AGGraphInvalidateValue(#3176)
a read outside any body, then observableObject.otherProperty += 1: nothing
the short version: FirstView's body runs as node #3176, then property changes
  AGGraphInvalidateValue(#3176)

A change invalidates only the view whose body read that property. A closure fires once: the second change to property says nothing until the first view's body has run again, which files a fresh closure. A read outside any body signs nothing up, because no list is open. And the short version, the one-view function, behaves the same as the general one.

8 · from lesson 16

Where the model and the real code part ways

The model keeps Apple's names and the shape of each part, and leaves out what is only bookkeeping. If you read Apple's code — the Observation library is open source, under swiftlang/swift — these are the differences you will meet.

The slot. _ThreadLocal.currentAccessList is a plain optional here; Apple keeps an UnsafeMutableRawPointer to a local list, and the list is created on the first read, not before the body.

Tickets. observableObject's registrar here keeps [AnyKeyPath: [closure]]; Apple's keeps observations: [Int: Observation] and lookups: [AnyKeyPath: Set<Int>], with an Int ticket for each closure, so that one closure filed under two key paths is stored once, and so that tracking.cancel() can remove it from every registrar once it has fired. The model removes only the closures under the key path that changed.

The table. State is a class here; Apple's is a struct inside a LockedBuffer, under a lock, because a property can change on any thread.

The change. withMutation here runs only willSet; Apple's also runs didSet after the store, the macro adds a _modify accessor, and shouldNotifyObservers skips the closures when the new value equals the old. And onChange in SwiftUI does not invalidate on the spot: it queues the invalidation through GraphHost.asyncTransaction, so the body runs once, after the store, however many properties changed.

The order of the last two steps. The model files the closures and then puts the previous list back. In withObservationTracking Apple restores the slot first, in a defer, and files the closures after; for SwiftUI's own path that order was never traced. Nothing depends on it: filing reads no property.

The node. AnyAttribute is a struct written here; what the trace saw was a 32-bit number, as the guide Inside the Attribute Graph explains.

9 · from lesson 18

Observations: every change of what a closure reads, as a sequence

withObservationTracking tells you once, about the next change only. Since iOS 26 (Swift 6.2, SE-0475) there is a loop built on it: Observations, an AsyncSequence whose elements are the values of a closure, one each time something the closure read is about to change — the path above, run again for every value. Lesson 18 uses it to save the trips whenever they change. The usual form is for await; it is sugar for an iterator and a while loop, and written out it shows what is going on:

The model — a view that saves on every change, with the loop written out
import SwiftUI
import Observation

// MARK: - A view that saves on every change, with the loop written out

struct AutosaveView: View {
    let observableObject: Model

    var body: some View {
        Text("\(observableObject.property)")
            .task {
                var changes = Observations({ observableObject.property }).makeAsyncIterator()
                while await changes.next() != nil {
                    print("save")
                }
            }
    }
}

next() hands back a value until the sequence ends, when it returns nil — the shape of every iterator — so != nil, or while let, is the whole loop. Two things about isolation are decided by where the closure is written. The closure handed to Observations is @isolated(any) and inherits the actor of the code around it: written in a .task, it runs on the main actor, where the model is, and reads it without await. And the value must be Sendable, because it crosses from that actor to whichever one the loop runs on. Here is the type, from the SDK:

From the iOS 27 SDK's Observation interface (shortened) — not code to type
public struct Observations<Element, Failure> : AsyncSequence, Sendable where Element : Sendable, Failure : Error {
  public enum Iteration : Sendable {
    case next(Element)
    case finish
  }
  public init(@_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Element)
  public static func untilFinished(@_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Iteration) -> Observations<Element, Failure>
  public struct Iterator : AsyncIteratorProtocol {
    public mutating func next(isolation iterationIsolation: isolated (any Actor)? = #isolation) async throws(Failure) -> Element?
  }
  public func makeAsyncIterator() -> Iterator
}

untilFinished is the form whose closure can end the sequence itself, by returning .finish instead of .next(value); a closure that throws ends it too.

What for await compiles down to. for await is that while loop, written by the compiler. While it is still checking types, it rewrites every for-in, await or not, into a hidden iterator and a while let — and it can do that for Observations only because Observations conforms to AsyncSequence: the rewrite calls makeAsyncIterator() and next(isolation:) by looking them up in that conformance, and a type with the two methods but no conformance is refused. Here is a loop over Observations on its own, and the while the compiler made of it, from swiftc -dump-ast:

The model — one loop over Observations, for the compiler to rewrite
// MARK: - One loop, for the compiler to rewrite

/// A `for await` over `Observations` with nothing else around it: the guide shows the `while` the compiler makes of it.
@MainActor
func watch(_ observableObject: Model) async {
    for await value in Observations({ observableObject.property }) {
        print("  value: \(value)")
    }
}
The while the compiler rewrote for await into, from swiftc -dump-ast (trimmed)
(desugared_loop=brace_stmt
  (pattern_binding_decl
    (pattern_entry
      (pattern_named type="Observations<Int, Never>.Iterator" "$value$generator"
      (init=call_expr type="Observations<Int, Never>.Iterator"
        (dot_syntax_call_expr type="() -> Observations<Int, Never>.Iterator"
          (declref_expr type="(Observations<Int, Never>) -> () -> Observations<Int, Never>.Iterator" decl="Observations.makeAsyncIterator()"
  (while_stmt
    (conditions=array
      (pattern
        (pattern_optional_some type="Int?"
          (pattern_named type="Int" "$element"
        (call_expr type="Int?"
          (dot_syntax_call_expr type="(isolated (any Actor)?) async -> Int?"
            (declref_expr type="(inout Observations<Int, Never>.Iterator) -> (isolated (any Actor)?) async -> Int?" decl="Observations.Iterator.next(isolation:)"
            (argument_list
              (argument inout
                (inout_expr type="inout Observations<Int, Never>.Iterator"
                  (declref_expr type="@lvalue Observations<Int, Never>.Iterator" decl="watch(_:).$value$generator"
          (argument_list labels="isolation:"
            (argument label="isolation"
              (current_context_isolation_expr type="(any Actor)?"
    (brace_stmt
      (pattern_binding_decl
        (pattern_entry
          (init=declref_expr type="Int" decl="watch(_:).$element"

A variable named $value$generator, set to Observations(…).makeAsyncIterator(), once; then a while whose condition is let $element = await $value$generator.next(isolation: #isolation) — the current_context_isolation_expr is #isolation, the actor the loop is on, which is the main actor here because watch is on it — and whose body binds your value to $element. Nothing further down the compiler knows there was ever a for. The guide await, in full follows the rewrite the rest of the way: the Swift it amounts to, the C++ in the compiler that does it, the error without the conformance, where next runs, and the two calls as they appear in the SIL.

Where the loop lives. Trips puts it in a .task on the window's root view. That works, at two small costs: a view can exist more than once — on a Mac or an iPad with two windows of the app open, each window's root view runs its own loop, and every change saves twice — and the loop's life is tied to a view rather than to the data. The loop can live in the model instead, started in init and cancelled in deinit:

The model — a model that saves itself
import SwiftUI
import Observation

// MARK: - A model that saves itself

/// The loop lives with the data: it starts with the model and ends with it. Both `[weak self]`s matter — with a
/// strong self the model would hold the task, the task the closure, and the closure the model, for ever.
@MainActor
@Observable
final class SavingModel {
    var property = 0

    @ObservationIgnored private var saving: Task<Void, Never>?

    init() {
        saving = Task { [weak self] in
            for await _ in Observations({ [weak self] in self?.property }) {
                self?.save()
            }
        }
    }

    isolated deinit {
        saving?.cancel()
        print("  freed")
    }

    func save() {
        print("  save: property is \(property)")
    }
}

Both [weak self]s matter. With a strong self, the model holds the task, the task holds the closure, and the closure holds the model, and nothing is ever freed; the run at the end of the next section shows this one freed. The shortest form of all is a didSet on the property: one save per change, and none at launch. The loop's advantage over it is that a burst of changes saves once, as the next section shows.

10 · from lesson 18

How Observations works

Apple's Observations is withObservationTracking in a loop, with two pieces of shared state: a dirty flag, and the continuations of iterators waiting for a change. makeAsyncIterator gives an iterator that remembers whether it has started.

next(), the first time, runs the closure inside withObservationTracking and returns what it gave — so the first value comes at once, which is the save at launch in Trips. The onChange it files in observableObject's registrar is one function, emitWillChange: resume every waiting continuation, or, if none is waiting, set the flag.

Every later time, next() first waits: it parks a continuation and suspends — unless the flag is set, in which case it clears the flag and goes straight on. Then it runs the closure under tracking again, which files a fresh onChange, and returns the new value. That is the whole loop, and it explains what a burst of changes does. The first of three changes in a row runs the filed closure, which wakes the waiter or sets the flag; that tracking is used up, so the next two changes find nothing filed in observableObject's registrar; and the loop's task, on the same actor as the code making the changes, gets its turn only when that code is done — so the value it reads is the one after all three. SE-0475 calls that transactional: one value per turn of the actor, never a half-made state.

Cancelling: the wait is wrapped in withTaskCancellationHandler, which resumes the parked continuation; next() then sees Task.isCancelled and ends the sequence for good — its state is set aside, and every later next() returns nil at once. That is why removing a view ends the loop in its .task. Since Swift 6.4 the tracking also listens for the object's deinit, not only its willSet.

Here is that loop, written small: the same withObservationTracking, a flag and a continuation. Apple's adds errors, untilFinished, and the book-keeping for a sequence iterated from more than one place.

The model — Observations, written small
import SwiftUI
import Observation

// MARK: - Observations, written small

/// What Observations does, in the fewest lines: one withObservationTracking per value, a flag for a change that
/// came while nobody was waiting, and a continuation to wait on. Apple's adds errors, `untilFinished`, and the
/// book-keeping for a sequence iterated from more than one place.
struct Changes<Element: Sendable>: AsyncSequence {
    typealias Failure = Never

    let read: @MainActor @Sendable () -> Element

    struct Iterator: AsyncIteratorProtocol {
        typealias Failure = Never

        let read: @MainActor @Sendable () -> Element
        let signal = Signal()
        var started = false

        mutating func next() async -> Element? {
            if started {
                // 2. wait for a change — or go straight on, if one came already
                await signal.wait()
            }
            started = true
            if Task.isCancelled {
                return nil                           // 4. a cancelled task ends the sequence
            }
            return await MainActor.run { [read, signal] in
                // 1. run the closure under tracking, as a body runs…
                withObservationTracking {
                    read()
                } onChange: {
                    // 3. …so the closure filed in observableObject's registrar wakes the waiter
                    signal.fire()
                }
            }
        }
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(read: read)
    }
}

/// The waiting: a parked continuation, or a flag when the change comes first.
final class Signal: @unchecked Sendable {
    private let lock = NSLock()
    private var dirty = false
    private var waiting: CheckedContinuation<Void, Never>?

    func fire() {
        lock.lock()
        let waiter = waiting
        waiting = nil
        if waiter == nil {
            dirty = true
        }
        lock.unlock()
        waiter?.resume()
    }

    func wait() async {
        await withTaskCancellationHandler {
            await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
                lock.lock()
                if dirty {
                    dirty = false
                    lock.unlock()
                    continuation.resume()
                } else {
                    waiting = continuation
                    lock.unlock()
                }
            }
        } onCancel: {
            fire()
        }
    }
}

The run starts a loop over the real Observations and changes the model, does the same over Changes, then makes a model that saves itself and lets it go:

The model — the run
let observableObject = Model()

print("a loop over Observations({ observableObject.property })")
let loop = Task {
    for await value in Observations({ observableObject.property }) {
        print("  value: \(value)")
    }
    print("  the loop ended")
}
try? await Task.sleep(for: .milliseconds(50))
print("property += 1")
observableObject.property += 1
try? await Task.sleep(for: .milliseconds(50))
print("otherProperty += 1")
observableObject.otherProperty += 1
try? await Task.sleep(for: .milliseconds(50))
print("property += 1, three times in a row")
observableObject.property += 1
observableObject.property += 1
observableObject.property += 1
try? await Task.sleep(for: .milliseconds(50))
print("cancel the loop's task")
loop.cancel()
await loop.value

print("the same loop over Changes, the version written here")
let another = Model()
let byHand = Task {
    for await value in Changes(read: { another.property }) {
        print("  value: \(value)")
    }
    print("  the loop ended")
}
try? await Task.sleep(for: .milliseconds(50))
print("property += 1")
another.property += 1
try? await Task.sleep(for: .milliseconds(50))
print("otherProperty += 1")
another.otherProperty += 1
try? await Task.sleep(for: .milliseconds(50))
print("property += 1, three times in a row")
another.property += 1
another.property += 1
another.property += 1
try? await Task.sleep(for: .milliseconds(50))
print("cancel the loop's task")
byHand.cancel()
await byHand.value

print("a model that saves itself, then goes away")
var saving: SavingModel? = SavingModel()
try? await Task.sleep(for: .milliseconds(50))
saving?.property += 1
try? await Task.sleep(for: .milliseconds(50))
saving = nil
try? await Task.sleep(for: .milliseconds(50))

It prints

Console
a loop over Observations({ observableObject.property })
  value: 0
property += 1
  value: 1
otherProperty += 1
property += 1, three times in a row
  value: 4
cancel the loop's task
  the loop ended
the same loop over Changes, the version written here
  value: 0
property += 1
  value: 1
otherProperty += 1
property += 1, three times in a row
  value: 4
cancel the loop's task
  the loop ended
a model that saves itself, then goes away
  save: property is 0
  save: property is 1
  freed

Five facts, the same lines for Apple's loop and for the one written here. The first value comes at once. A change to the property the closure read gives one value. A change to a property it did not read gives nothing. Three changes in a row give one value, the state after all three. And cancelling the task ends the sequence. Last, the model that saved itself was freed: the two [weak self]s broke the cycle, and its deinit cancelled the loop.

Further reading