[Pitch] Advanced Observation Tracking

Introduction

Observation has one primary public entry point for observing the changes to @Observable types. This proposal adds two new versions that allow more fine-grained control and advanced behaviors. In particular, it is not intended to be a natural progression for all users of Observation, but instead a set of specialized tools for advanced use cases such as developing middleware infrastructure or the underpinnings to widgeting systems. Most developers using Observation will still be best served by using the @Observable macro and possibly in conjunction with the Observations type for iterating transactional values. However, in the advanced use cases where it is needed, this proposal fills a much needed gap.

Motivation

Asynchronous observation serves a majority of use cases. However, when interfacing with synchronous systems, there are two major behaviors that the current Observations and withObservationTracking do not service.

The existing withObservationTracking API can only inform observers of events that will occur and may coalesce events that arrive in quick succession. Yet, some typical use cases require immediate and non-coalesced events, such as when two data models need to be synchronized together after a value has been set. The existing synchronization may also need to know when models are no longer available due to deinitialization.

Additionally, some use cases do not have a modern replacement for continuous events without an asynchronous context. This often occurs in more-established, existing UI systems.

Proposed solution

Two new mechanisms will be added: 1) an addendum to the existing withObservationTracking that accepts options to control when/which changes are observed, and 2) a continuous variant that re-observes automatically after coalesced events.

Detailed design

Some of these behaviors have been existing and under evaluation by SwiftUI itself, and the API shapes exposed here apply lessons learned from that usage.

The two major, top-level interfaces added are a new withObservationTracking method that takes an options parameter and a withContinuousObservation that provides a callback with behavior similar to the Observations API.

public func withObservationTracking<Result: ~Copyable, Failure: Error>(
  options: ObservationTracking.Options,
  _ apply: () throws(Failure) -> Result,
  onChange: @escaping @Sendable (borrowing ObservationTracking.Event) -> Void
) throws(Failure) -> Result

public func withContinuousObservation(
  options: ObservationTracking.Options,
  @_inheritActorContext apply: @isolated(any) @Sendable @escaping (borrowing ObservationTracking.Event) -> Void
) -> ObservationTracking.Token

The new types are nested in a ObservationTracking namespace which prevents potential name conflicts. This is an existing structure used for the internal mechanisms for observation tracking today; it will be (as a type and no existing methods) promoted from SPI to API.

public struct ObservationTracking { }

The options parameter to the two new functions have 3 non-exclusive variations that specify which kinds of events the observer is interested in. These control when events are passed to the event closure and support the .willSet, .didSet, or .deinit side of events.

If an observation is setup such that it tracks all three, then a mutation of a property will fire two events (a .willSet and a .didSet) per setting of the property and one event when the observable type that is tracked is deinitialized.

extension ObservationTracking {
  public struct Options {
    public init()

    public static var willSet: Options { get }
    public static var didSet: Options { get }
    public static var `deinit`: Options { get }
  }
}

extension ObservationTracking.Options: SetAlgebra { }
extension ObservationTracking.Options: Sendable { }

Note: ObservationTracking.Options is a near miss of OptionSet; since its internals are a private detail, SetAlgebra was chosen instead. Altering this would potentially expose implementation details that may not be ABI stable or sustainable for API design.

When an observation closure is invoked there are four potential events that can occur: a .willSet or .didSet when a property is changed, an .initial when the continuous events are setup, or a .deinit when an @Observable type is deallocated. These are derived by the existing language level property observers and behaviors around observation.

Beyond the kind of event, the event can also be matched to a given known key path. This allows for detecting which property changed without violating the access control of types.

Lastly, the Event type has an option to cancel the observation, which prevents any further events from being fired. For example, an event triggered on the .willSet can cancel the event, and there will not be a subsequent event for the corresponding .didSet (provided those are registered as options).

extension ObservationTracking {
  public struct Event: ~Copyable {
    public struct Kind: Equatable, Sendable {
      public static var initial: Kind { get }
      public static var willSet: Kind { get }
      public static var didSet: Kind { get }
      public static var `deinit`: Kind { get }
    }

    public var kind: Kind { get }

    public func matches(_ keyPath: PartialKeyPath<some Observable>) -> Bool
    public func cancel()
  }
}

The event matching function can be used to determine which property was responsible for the event. The following sample tracks both the properties foo and bar, when bar is then changed the onChange event will match that specific keypath.

withObservationTracking(options: [.willSet]) {
  print(myObject.foo + myObject.bar)
} onChange: { event in
  if event.matches(\MyObject.foo) {
    print("got a change of foo")
  }
  if event.matches(\MyObject.bar) {
    print("got a change of bar")
  }
}

myObject.bar += 1

The sample above is expected to print out that it "got a change of bar" once since it only was registered with the options of willSet. The matching of events happen for either willSet or didSet events, but will not match any cases of deinit events.

The deinit event happens when an object being observed is deinitialized. The following example will trigger a deinit.


var myObject: MyObject? = MyObject()

withObservationTracking(options: [.deinit]) {
  if let myObject {
    print(myObject.foo + myObject.bar)
  }
} onChange: { event in
  print("got a deinit event")
}

myObject = nil

The other form of observation is the continuous version. It is something that can happen for more than one property modification. To that end, an external token needs to be held to ensure that observation continues. Either no longer holding that token or explicitly consuming it via the cancel method unregisters that observation and prevents any subsequent callbacks to the observation's closure.

extension ObservationTracking {
  public struct Token: ~Copyable {
    public consuming func cancel()
  }
}

Behavior & Example Usage

_ = withObservationTracking(options: [.willSet, .didSet, .deinit]) {
  observable.property
} onChange: { event in
  switch event.kind {
  case .initial: print("initial event")
  case .willSet: print("property will set")
  case .didSet: print("property did set")
  case .deinit: print("an Observable instance deallocated")
  }
}

observable.property += 1 

At the invocation of the mutation of the property (the assignment part of the += 1) the following is then printed:

property will set
property did set

Breaking that down a bit: at the .willSet event, the value of the property is not yet materialized/stored in the observable instance. Once the .didSet event occurs, that property is materialized into that container.

Then, when the observable is deallocated, the following is printed:

an Observable instance deallocated

While any weak reference to the object will be nil when a .deinit event is received, the object may or may not have been deinitialized yet.

The continuous version works similarly except that it has one major behavioral difference: the closure will be invoked after the event at the next suspension point of the isolating calling context. That means that if withContinuousObservation is called in a @MainActor isolation, then the closure will always be called on the main actor.

@MainActor
final class Controller {
  var view: MyView
  var model: MyObservable
  let synchronization: ObservationTracking.Token

  init(view: MyView, model: MyObservable) {
    synchronization = withContinuousObservation(options: [.willSet]) { [view, model] event in
      view.label.text = model.someStringValue
    }
  }
}

Source compatibility

Since the types are encapsulated in the ObservationTracking namespace they provide no interference with existing sources.

The new methods are clear overloads given new types or entirely new names so there are no issues with source compatibility for either of them.

ABI compatibility

The only note per ABI impact is the ObservationTracking.Options; the internal structural type of the backing value is subject to change and must be maintained as SetAlgebra instead of OptionSet.

Implications on adoption

The primary implications of adoption of this is reduction in code when it comes to the usages of existing systems; initial experimentation has shown that projects can use these tools to safely migrate from pre-concurrency frameworks that required synchronous callback behaviors around values over time to a concurrency safe environment improving both safety and reducing a considerable amount of boiler plate.

Future directions

The ObservationTracking.Options type reflects the interactions of properties for their mutation characteristics by the language. If at such time there are additional modifications to that system it should be strongly considered as part of the expected interactions from Observation and should be added as a new option. For example if a new modified property observer were to be added and the @Observable macro adopts that then the options should be considered if an addition is needed.

Alternatives considered

The withContinuousObservation could have a default parameter of .willSet to mimic the quasi default behavior of withObservationTracking - in that the existing non-options version of that function acts in the same manner as the new version passing .willSet and no other options (excluding the closure signature being different). Since the closure makes that signature only a near miss this default behavior was dismissed and the users of the withContiuousObservation API then should pass the explicit options as needed.

Acknowledgments

Special thanks to Jonathan Flat, Guillaume Lessard for editing/review contributions.

36 Likes

Ahh… didSet semantics! Hooray!

One potential idea for the "Alternatives Considered" would be to address why the existing didSet support on withObservationTracking that is currently only used in SwiftUI is not shipped as an official and supported API.

1 Like

Our precedent here is to declare a namespace as an empty enum. Is there a reason this is a struct instead?

Is there a reason for dropping the “tracking” suffix from withContinuousObservation other than brevity? It’s unfortunate that the continuous variant of observation tracking isn’t called withContinuousObservationTracking.

I'd be fine with calling it that if folks feel that is not too wordy and reflective of the behavior better

Okay, I do think there’s a benefit in keeping the consistency across both variants of observation tracking. (The alternative, whose ship has sailed, would be to drop the suffix from both: withObservation and withContinuousObservation. But, IIRC there were reasons for not going with withObservation and I would think those same reasons would apply to the continuous variant.)

2 Likes

As I wrote in the pitch: this is an existing type and serves a decent placeholder to the namespace of things. I am repurposing the SPI as API in this case; else it would be an empty enumeration.

Since it's being namespaced now under ObservationTracking, even "Observation" is (triply) redundant (Observation::ObservationTracking.withObservationTracking). For maximal non-redundancy this would be Observation::Tracking.with. [Looks like the actual function is still a top-level API and only the options are namespaced. But perhaps if we're surfacing a namespace there is opportunity to revisit?]

Can you elaborate more on deinit semantics? If I observe multiple objects in the body will I get an update for each one deinitializing?

1 Like

If I have a type like this:

final class SearchModel: ObservableObject {
  @Published var input = ""
  @Published var scope = ""

  @Published private(set) var results: [SearchResult] = []

  init() {
    Publishers.CombineLatest($input, $scope)
      //.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
      .removeDuplicates(by: ==)
      .map { search(for: $0, in: $1) }
      .switchToLatest()
      .assign(to: &$results)
  }
}

struct SearchView: View {
  @StateObject var model = SearchModel()

  var body: some View {
    // bind $model.input, $model.scope to text fields, 
    // present model.results
  }
}

Will this proposed API be suitable to replace Combine in this case? Can I use continuous observation tracking to make an @Observable model react to changes to input and scope, to produce some result that isn't merely a computed property of its inputs?

Can I use it to also implement some kind of debouncer on user input, to allow the user to type fast and only search when slowing down?

I'm not arguing for dropping Combine for the sake of dropping it, but merely want to understand how Published.Publisher is overlapping with the proposed APIs, if at all.

1 Like

This does not really replace @Published (instead that is the @Observable macro + Observations). This pitch fits the same shape as the .assign(to:) or .sink use cases.

Just like .willSet or .didSet the events for the non continuous version is a first-come first-served event. So if you have two @Observable things being accessed and the withObservationTracking passes in the .deinit option it will fire an event on the first deinitialization of the underlying ObservationRegistrar (which is owned by the @Observable instance).

So for example if you attach a debugger to the process and break on the onChange closure for a .willSet your backtrace will unwind back up into the property setter. Likewise the .deinit option will unwind the backtrace to the deinit of that @Observable type.

It is worth noting that that callback is in the process of deinitialization; so you have to be somewhat careful when expectations are held around the weak reference loading of objects. It will mean that any weak-load will return nil.

Happy to see ongoing work in this space. Some questions:

Deinitialization

  • How does a client know what to do with a deinit event kind, given a single observable object may compose over an arbitrary number of underlying observable objects in its implementation? (Or, similarly, the tracking closure may directly reference several observable objects.)
    • An author who wrote both the observable object’s implementation and the client observing it could make this work in a simple case, but requiring a client to know the number of observation registrars in an object graph feels fragile.
    • Did you consider/reject requiring that registration for the deinit event kind explicitly specify which object instances’ deinitialization should trigger emission of the event?

Event Matching

  • Is it feasible and/or desirable to teach ObservationTracking.Event to match on instances of objects rather than only key paths?
    • If the tracking closure references several observable objects of the same concrete type (multiple independent contributors to some higher-level state), the key path-only ObservationTracking.Event/matches(_:) method cannot distinguish between objects.
  • Does the utility of ObservationTracking.Event/matches(_:)depend on clients knowing that they’re observing stored properties?
    • Considering the object graph below, if a client who believes they’re observing \Parent.value receives an event and tests event.matches(\Parent.value), would that test unconditionally return false because the actual triggering change was \Child.childValue?
@Observable private class Child { 
    var childValue: Int 
}

@Observable class Parent { 
    private var child: Child
    var value: Int { child.childValue } 
}

Isolation

  • How do the following two statements from the pitch reconcile in the behavior of observing willSet and didSet in the continuous API? Given no suspension point between willSet and didSet, does the first statement only hold for the non-continuous API?
    • at the .willSet event, the value of the property is not yet materialized/stored in the observable instance. Once the .didSet event occurs, that property is materialized into that container.

    • The continuous version works similarly except that it has one major behavioral difference: the closure will be invoked after the event at the next suspension point of the isolating calling context.

1 Like

There really isn't a way without breaking things to flip that around, but mainly this is rejected because the deinitialization participation is more there for the composition of weakly captured objects within the closure - it basically fixes a long standing issue where observation could hang on weakly captured objects (very very small race condition chance for it to occur) that the deinit fixes. In truth even if we totally reject public use of the deinit; there will be clients that WILL use some form of it to fix those races and I am of the opinion that if we face the potential of this for SwiftUI or Observations then it can happen in other systems.

This is not possible to expose. The long story made short is that it would result in a cyclical reference to the containing object and create leaks.

Your example is redundant btw, the Parent doesn't need to be @Observable at all so there is no real issue with disambiguation. But if you are worried about replacement of the child then the event would be \Parent.child not \Parent.value.

The continuous form is the transactional behavior just like Observations so that first statement is about the non-continuous version. Observing willSet versus didSet on a continuous form is roughly isomorphic for things that run with schedulers based around RunLoop or DispatchQueue (I could imagine a custom executor might be able to somehow influence a difference). In a vast majority of scenarios you cannot tell the difference between a transactional bound isolation based continuous observation and if it is willSet or didSet triggered. The deinit however is something that is meaningful and I could imagine other future additions to the options influencing the continuous versions.

Thanks Philippe!

Is the expected usage pattern for receiving any deinit event “go reevaluate all state being tracked” (in the SwiftUI example this is presumably View/body)? Some elaboration on why the deinit event is useful would be a useful addition to the pitch text, as I’m not familiar with another observation system that exposes this hook.

Is there no place to break that cycle manually after the event has been emitted to clients? I’d expect the observation internals to own the lifetime of that event instance, modulo a client who escapes the event from their tracking closure (which would surprise me, or is that a valid usage pattern?).

Sorry, let me clarify the scenario. It’s fine for Parent to have its own observable state and I’m not worried about replacement of the value of child, only the propagation of an underlying change in the Child instance:

// Intentionally not exposed to clients
@Observable private class Child {
    var childValue = 0
}

@Observable public class Parent {
    private var parentValue = 0 // Independent Observable state at the parent level, not that a client could know!

    // Child is strictly an implementation detail
    private let child = Child()

    public init() {}

    // Computed property of interest to a client
    public var totalValue: Int { child.childValue + parentValue } 

    public func incrementChild() { 
        // Produces an observable change in `totalValue`
        child.childValue += 1 
    } 
}

Then in the client:

let parent = Parent()
let token = withObservationTracking(options: [.willSet, .didSet]) {
    parent.totalValue
} onChange: { event in 
    // `false`, but this is the only property whose existence I know about as a client!
    // `true` if `totalValue` were a stored property (e.g. a cache of its computed value) instead
    event.matches(\Parent.totalValue)
}

// Some mutation to parent happens elsewhere, intending to be observed by the above:
parent.incrementChild()

My point is that the ability to depend on matches(_:) to disambiguate between changes to the observed object (the reason for matches(_:) to exist, to my understanding) depends on observed properties being stored, not computed. (My assumption here is that the @Observable macro only applies automatic tracking to declared stored properties, but please correct me if that has changed.)

Swift tries hard to mask over whether a property is stored or computed across a module boundary, so an otherwise API- and ABI-compatible change to turn a stored property into a computed one (or vice versa) would change the behavior of matches(_:), which is subtle.

This explanation helps, thanks! I’d find a note like this valuable in the pitch text, perhaps alongside a discussion (or rejection) of whether the type representing options for continuous observation ought to be different from the type representing options for non-continuous observation.

1 Like

This looks great, Philippe!

Question: For the withObservationTracking example output in the Behavior & Example Usage section, should there be an additional line to for the .initial case (just before the ”property will set” output)?

The initial is only for continuous observation; in that it is the result from when that initially runs w/o a triggering from an actual property change.

1 Like

Does this proposal at all address the issue where observing properties of objects that can be modified during the execution of withObservationTracking can potentially lose events? As I recall, registering for observations during willSet was a solution there, but I assume Observations under this proposal still does not actually do that because of the re-entrancy problems you were concerned about. So is this just unrelated to that issue?

`withObservationTracking` can miss concurrent/coincident updates · Issue #83359 · swiftlang/swift · GitHub has not yet been fixed for the existing APIs, and personally I'd prefer to see the existing observation APIs working before we add new ones…

2 Likes

So this does fix issue #83359 and addresses some other issues with regards to missing events by allowing the underlying implementation to actually hook against the event of deinitialization (and other parts) by being able to pass the flags without regressing the existing behavior (which was the reason why we couldn't just land a fix previously).

So to answer your question: is it related? Tangentially; this opens up the fix to be able to be landed as part of the new feature. Additionally it grants the tools for other folks to address that potential issue too.

Note; so far my tests of that issue locally with the experimental version has passed hundreds of thousands of iterations just fine - but like with any sort of slim race condition it is difficult to prove that it cannot happen at all. I have yet to figure out a way to deterministically test that it is solved; the report gives a stochastic method but that is not very reliable for testing.

2 Likes