From b247eeef45993f0626696fd6002069ad7fb60697 Mon Sep 17 00:00:00 2001 From: Philippe Hausler Date: Wed, 19 Nov 2025 10:41:04 -0800 Subject: [PATCH 1/4] [Observation] Advanced withObservation functions --- .../NNNN-advanced-observation-tracking.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 proposals/NNNN-advanced-observation-tracking.md diff --git a/proposals/NNNN-advanced-observation-tracking.md b/proposals/NNNN-advanced-observation-tracking.md new file mode 100644 index 0000000000..d65b92c4c8 --- /dev/null +++ b/proposals/NNNN-advanced-observation-tracking.md @@ -0,0 +1,217 @@ +# Advanced Observation Tracking + +* Proposal: [SE-NNNN](NNNN-advanced-observation-tracking.md) +* Authors: [Philippe Hausler](https://github.com/phausler) +* Review Manager: TBD +* Status: **Awaiting review** + +## 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. + +## 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 sychronization 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. + +```swift +public func withObservationTracking( + 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 mecahnisms for observation tracking today; it will be (as a type and no existing methods) promoted from SPI to API. + +```swift +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. + +```swift +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. + +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). + +```swift +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) -> 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. + +```swift +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". 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. + +```swift + +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. + +```swift +extension ObservationTracking { + public struct Token: ~Copyable { + public consuming func cancel() + } +} +``` + +## Behavior & Example Usage + +```swift +_ = 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 strucural type of the backing value is subject to change and must be maintained as `SetAlgebra` insted 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. + +## Future directions + +None at this time. + +## 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 beahvior was dismissed and the users of the `withContiuousObservation` API then should pass the explicit options as needed. + +## Acknowledgments + +Special thanks to [Jonathan Flat](https://github.com/jrflat), [Guillaume Lessard](https://github.com/glessard) for editing/review contributions. From e339e0c8782c51fdce1f1660979de0c3f0eeb5fb Mon Sep 17 00:00:00 2001 From: Philippe Hausler Date: Wed, 19 Nov 2025 10:56:28 -0800 Subject: [PATCH 2/4] Clarify the sample around matching --- proposals/NNNN-advanced-observation-tracking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/NNNN-advanced-observation-tracking.md b/proposals/NNNN-advanced-observation-tracking.md index d65b92c4c8..77b7afac42 100644 --- a/proposals/NNNN-advanced-observation-tracking.md +++ b/proposals/NNNN-advanced-observation-tracking.md @@ -108,7 +108,7 @@ withObservationTracking(options: [.willSet]) { myObject.bar += 1 ``` -The sample above is expected to print out that it "got a change of bar". The matching of events happen for either willSet or didSet events, but will not match any cases of deinit events. +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. From 7d0875af3d7c7fb63f816bda4bc9d1c44baf6d20 Mon Sep 17 00:00:00 2001 From: Philippe Hausler Date: Tue, 9 Dec 2025 10:23:18 -0800 Subject: [PATCH 3/4] Adjust for some of the initial pitch feedback --- .../NNNN-advanced-observation-tracking.md | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/proposals/NNNN-advanced-observation-tracking.md b/proposals/NNNN-advanced-observation-tracking.md index 77b7afac42..73cab5d74b 100644 --- a/proposals/NNNN-advanced-observation-tracking.md +++ b/proposals/NNNN-advanced-observation-tracking.md @@ -7,13 +7,13 @@ ## 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. +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 sychronization may also need to know when models are no longer available due to deinitialization. +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. @@ -25,7 +25,7 @@ Two new mechanisms will be added: 1) an addendum to the existing `withObservatio 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. +The two major, top-level interfaces added are a new `withObservationTracking` method that takes an `options` parameter and a `withContinuousObservationTracking` that provides a callback with behavior similar to the `Observations` API. ```swift public func withObservationTracking( @@ -34,13 +34,13 @@ public func withObservationTracking( onChange: @escaping @Sendable (borrowing ObservationTracking.Event) -> Void ) throws(Failure) -> Result -public func withContinuousObservation( +public func withContinuousObservationTracking( 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 mecahnisms for observation tracking today; it will be (as a type and no existing methods) promoted from SPI to API. +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. ```swift public struct ObservationTracking { } @@ -67,7 +67,7 @@ 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. +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. @@ -172,7 +172,7 @@ 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. +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 `withContinuousObservationTracking` is called in a `@MainActor` isolation, then the closure will always be called on the main actor. ``` @MainActor @@ -182,7 +182,7 @@ final class Controller { let synchronization: ObservationTracking.Token init(view: MyView, model: MyObservable) { - synchronization = withContinuousObservation(options: [.willSet]) { [view, model] event in + synchronization = withContinuousObservationTracking(options: [.willSet]) { [view, model] event in view.label.text = model.someStringValue } } @@ -198,19 +198,21 @@ The new methods are clear overloads given new types or entirely new names so the ## ABI compatibility -The only note per ABI impact is the `ObservationTracking.Options`; the internal strucural type of the backing value is subject to change and must be maintained as `SetAlgebra` insted of `OptionSet`. +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. +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 -None at this time. +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 beahvior was dismissed and the users of the `withContiuousObservation` API then should pass the explicit options as needed. +The `withContinuousObservationTracking` 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. + +It was initially considered to promote the existing SPI to API and call it a day, this was dismissed since it is missing the flexibility of being able to extend via the options parameter (for example to the `deinit`). Also doing so poses potential confusion around the suggested paths of progressive disclosure around transactions, willSet and didSet semantics. Since specifying an option is definitely a more specific design requirement that is a considerably more favored public exposition. ## Acknowledgments From 9a476103aa48b978ad272ce14670601e1c8435ea Mon Sep 17 00:00:00 2001 From: Stephen Canon Date: Tue, 9 Dec 2025 14:09:44 -0500 Subject: [PATCH 4/4] Update NNNN-advanced-observation-tracking.md Add link to pitch thread --- proposals/NNNN-advanced-observation-tracking.md | 1 + 1 file changed, 1 insertion(+) diff --git a/proposals/NNNN-advanced-observation-tracking.md b/proposals/NNNN-advanced-observation-tracking.md index 73cab5d74b..e49f78c0f7 100644 --- a/proposals/NNNN-advanced-observation-tracking.md +++ b/proposals/NNNN-advanced-observation-tracking.md @@ -4,6 +4,7 @@ * Authors: [Philippe Hausler](https://github.com/phausler) * Review Manager: TBD * Status: **Awaiting review** +* Review: ([pitch](https://forums.swift.org/t/pitch-advanced-observation-tracking/83521)) ## Introduction