From a8f3be225a72aac6b286d31033ed091de8147792 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 11 May 2025 15:41:30 +0200 Subject: [PATCH 01/11] Expose `observe` overloads for separate tracking and application of changes --- .../SwiftNavigation/NSObject+Observe.swift | 46 +++++ Sources/SwiftNavigation/Observe.swift | 163 +++++++++++++++++- .../ObserveTests+Nesting.swift | 132 ++++++++++++++ Tests/SwiftNavigationTests/ObserveTests.swift | 118 +++++++++++++ Tests/UIKitNavigationTests/ObserveTests.swift | 15 -- 5 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 Tests/SwiftNavigationTests/ObserveTests+Nesting.swift delete mode 100644 Tests/UIKitNavigationTests/ObserveTests.swift diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index 580e9574d..8ea3f0e9a 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -110,6 +110,52 @@ observe { _ in apply() } } + /// Observe access to properties of an observable (or perceptible) object. + /// + /// This tool allows you to set up an observation loop so that you can access fields from an + /// observable model in order to populate your view, and also automatically track changes to + /// any fields accessed in the tracking parameter so that the view is always up-to-date. + /// + /// - Parameter tracking: A closure that contains properties to track + /// - Parameter onChange: Invoked when the value of a property changes + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ tracking: @escaping @MainActor @Sendable () -> Void, + onChange apply: @escaping @MainActor @Sendable () -> Void + ) -> ObserveToken { + observe { _ in apply() } + } + + /// Observe access to properties of an observable (or perceptible) object. + /// + /// A version of ``observe(_:)`` that is passed the current transaction. + /// + /// - Parameter tracking: A closure that contains properties to track + /// - Parameter onChange: Invoked when the value of a property changes + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ tracking: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void, + onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + ) -> ObserveToken { + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + tracking(transaction) + } + } onChange: { transaction in + MainActor._assumeIsolated { + apply(transaction) + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } + } + tokens.append(token) + return token + } + /// Observe access to properties of an observable (or perceptible) object. /// /// A version of ``observe(_:)`` that is passed the current transaction. diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index 891052840..98f51d452 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -62,6 +62,72 @@ import ConcurrencyExtras observe(isolation: isolation) { _ in apply() } } + /// Tracks access to properties of an observable model. + /// + /// This function allows one to minimally observe changes in a model in order to + /// react to those changes. For example, if you had an observable model like so: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// var count = 0 + /// } + /// ``` + /// + /// Then you can use `observe` to observe changes in the model. For example, in UIKit you can + /// update a `UILabel`: + /// + /// ```swift + /// observe { _ = model.value } onChange: { [weak self] in + /// guard let self else { return } + /// countLabel.text = "Count: \(model.count)" + /// } + /// ``` + /// + /// Anytime the `count` property of the model changes the trailing closure will be invoked again, + /// allowing you to update the view. Further, only changes to properties accessed in the trailing + /// closure will be observed. + /// + /// > Note: If you are targeting Apple's older platforms (anything before iOS 17, macOS 14, + /// > tvOS 17, watchOS 10), then you can use our + /// > [Perception](http://github.com/pointfreeco/swift-perception) library to replace Swift's + /// > Observation framework. + /// + /// This function also works on non-Apple platforms, such as Windows, Linux, Wasm, and more. For + /// example, in a Wasm app you could observe changes to the `count` property to update the inner + /// HTML of a tag: + /// + /// ```swift + /// import JavaScriptKit + /// + /// var countLabel = document.createElement("span") + /// _ = document.body.appendChild(countLabel) + /// + /// let token = observe { _ = model.count } onChange: { + /// countLabel.innerText = .string("Count: \(model.count)") + /// } + /// ``` + /// + /// And you can also build your own tools on top of `observe`. + /// + /// - Parameters: + /// - isolation: The isolation of the observation. + /// - tracking: A closure that contains properties to track. + /// - onChange: A closure that is triggered after some tracked property has changed + /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token + /// is deallocated. + public func observe( + isolation: (any Actor)? = #isolation, + @_inheritActorContext _ tracking: @escaping @Sendable () -> Void, + @_inheritActorContext onChange apply: @escaping @Sendable () -> Void + ) -> ObserveToken { + observe( + isolation: isolation, + { _ in tracking() }, + onChange: { _ in apply() } + ) + } + /// Tracks access to properties of an observable model. /// /// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``. @@ -87,6 +153,36 @@ import ConcurrencyExtras } ) } + + +/// Tracks access to properties of an observable model. +/// +/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``. +/// +/// - Parameters: +/// - isolation: The isolation of the observation. +/// - tracking: A closure that contains properties to track. +/// - onChange: A closure that is triggered after some tracked property has changed +/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token +/// is deallocated. + public func observe( + isolation: (any Actor)? = #isolation, + @_inheritActorContext _ tracking: @escaping @Sendable (UITransaction) -> Void, + @_inheritActorContext onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void + ) -> ObserveToken { + let actor = ActorProxy(base: isolation) + return observe( + tracking, + onChange: apply, + task: { transaction, operation in + Task { + await actor.perform { + operation() + } + } + } + ) + } #endif private actor ActorProxy { @@ -105,7 +201,8 @@ private actor ActorProxy { func observe( _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, task: @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void ) -> Void = { Task(operation: $1) } @@ -138,6 +235,45 @@ func observe( return token } +func observe( + _ tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void, + onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } +) -> ObserveToken { + let token = ObserveToken() + SwiftNavigation.onChange( + of: tracking, + perform: { [weak token] transaction in + guard + let token, + !token.isCancelled + else { return } + + var perform: @Sendable () -> Void = { apply(transaction) } + for key in transaction.storage.keys { + guard let keyType = key.keyType as? any _UICustomTransactionKey.Type + else { continue } + func open(_: K.Type) { + perform = { [perform] in + K.perform(value: transaction[K.self]) { + perform() + } + } + } + open(keyType) + } + perform() + }, + task: task + ) + return token +} + private func onChange( _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, task: @escaping @Sendable ( @@ -153,6 +289,31 @@ private func onChange( } } +private func onChange( + of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void, + perform action: @escaping @Sendable (_ transaction: UITransaction) -> Void, + apply: Bool = true, + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void +) { + if apply { action(.current) } + + withPerceptionTracking { + tracking(.current) + } onChange: { + task(.current) { + onChange( + of: tracking, + perform: action, + apply: true, + task: task + ) + } + } +} + /// A token for cancelling observation. /// /// When this token is deallocated it cancels the observation it was associated with. Store this diff --git a/Tests/SwiftNavigationTests/ObserveTests+Nesting.swift b/Tests/SwiftNavigationTests/ObserveTests+Nesting.swift new file mode 100644 index 000000000..19da7d767 --- /dev/null +++ b/Tests/SwiftNavigationTests/ObserveTests+Nesting.swift @@ -0,0 +1,132 @@ +import SwiftNavigation +import Perception +import XCTest + +class NestingObserveTests: XCTestCase { + #if swift(>=6) + func testIsolation() async { + await MainActor.run { + var count = 0 + let token = SwiftNavigation.observe { + count = 1 + } + XCTAssertEqual(count, 1) + _ = token + } + } + #endif + + #if !os(WASI) + @MainActor + func testNestedObservation() async { + let object = ParentObject() + let model = ParentObject.Model() + + MockTracker.shared.entries.removeAll() + object.bind(model) + + XCTAssertEqual( + MockTracker.shared.entries.map(\.label), + [ + "ParentObject.bind", + "ParentObject.value.didSet", + "ChildObject.bind", + "ChildObject.value.didSet", + ] + ) + + MockTracker.shared.entries.removeAll() + model.child.value = 1 + + await Task.yield() + + XCTAssertEqual( + MockTracker.shared.entries.map(\.label), + [ + "ChildObject.Model.value.didSet", + "ChildObject.value.didSet", + ] + ) + } + #endif +} + +#if !os(WASI) + fileprivate class ParentObject: @unchecked Sendable { + var tokens: Set = [] + let child: ChildObject = .init() + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ParentObject.bind") + + tokens = [ + observe { _ = model.value } onChange: { [weak self] in + self?.value = model.value + }, + observe { _ = model.child } onChange: { [weak self] in + self?.child.bind(model.child) + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") } + } + + var child: ChildObject.Model = .init() { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") } + } + } + } + + fileprivate class ChildObject: @unchecked Sendable { + var tokens: Set = [] + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ChildObject.bind") + + tokens = [ + observe { _ = model.value } onChange: { [weak self] in + self?.value = model.value + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet") } + } + } + } + + fileprivate final class MockTracker: @unchecked Sendable { + static let shared = MockTracker() + + struct Entry { + var label: String + var value: Any + } + + var entries: [Entry] = [] + + init() {} + + func track( + _ value: Any, + with label: String + ) { + entries.append(.init(label: label, value: value)) + } + } +#endif diff --git a/Tests/SwiftNavigationTests/ObserveTests.swift b/Tests/SwiftNavigationTests/ObserveTests.swift index afafe1731..0c07152b4 100644 --- a/Tests/SwiftNavigationTests/ObserveTests.swift +++ b/Tests/SwiftNavigationTests/ObserveTests.swift @@ -1,4 +1,5 @@ import SwiftNavigation +import Perception import XCTest class ObserveTests: XCTestCase { @@ -31,4 +32,121 @@ class ObserveTests: XCTestCase { XCTAssertEqual(count, 2) } #endif + + #if !os(WASI) + @MainActor + func testNestedObservation() async { + let object = ParentObject() + let model = ParentObject.Model() + + MockTracker.shared.entries.removeAll() + object.bind(model) + + XCTAssertEqual( + MockTracker.shared.entries.map(\.label), + [ + "ParentObject.bind", + "ParentObject.value.didSet", + "ChildObject.bind", + "ChildObject.value.didSet", + ] + ) + + MockTracker.shared.entries.removeAll() + model.child.value = 1 + + await Task.yield() + + // See ObserveTests+Nesting for the correct approah for nested observations + XCTAssertEqual( + MockTracker.shared.entries.map(\.label), + [ + "ChildObject.Model.value.didSet", + "ChildObject.value.didSet", + "ChildObject.bind", // redundant update + "ChildObject.value.didSet" + ] + ) + } + #endif } + +#if !os(WASI) + fileprivate class ParentObject: @unchecked Sendable { + var tokens: Set = [] + let child: ChildObject = .init() + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ParentObject.bind") + + tokens = [ + observe { [weak self] in + self?.value = model.value + }, + observe { [weak self] in + self?.child.bind(model.child) + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") } + } + + var child: ChildObject.Model = .init() { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") } + } + } + } + + fileprivate class ChildObject: @unchecked Sendable { + var tokens: Set = [] + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ChildObject.bind") + + tokens = [ + observe { [weak self] in + self?.value = model.value + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet") } + } + } + } + + fileprivate final class MockTracker: @unchecked Sendable { + static let shared = MockTracker() + + struct Entry { + var label: String + var value: Any + } + + var entries: [Entry] = [] + + init() {} + + func track( + _ value: Any, + with label: String + ) { + entries.append(.init(label: label, value: value)) + } + } +#endif diff --git a/Tests/UIKitNavigationTests/ObserveTests.swift b/Tests/UIKitNavigationTests/ObserveTests.swift deleted file mode 100644 index dd50257fc..000000000 --- a/Tests/UIKitNavigationTests/ObserveTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -#if canImport(UIKit) - import UIKitNavigation - import XCTest - - class ObserveTests: XCTestCase { - @MainActor - func testCompiles() { - var count = 0 - observe { - count = 1 - } - XCTAssertEqual(count, 1) - } - } -#endif From 5df4eb9c15b282ae661eb47128ba0b5078ded3f7 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Tue, 20 May 2025 15:27:05 +0200 Subject: [PATCH 02/11] Add minor performance optimization for onChange(of:perform:task:) --- Sources/SwiftNavigation/Observe.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index 98f51d452..a5dd548a3 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -291,14 +291,13 @@ private func onChange( private func onChange( of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void, - perform action: @escaping @Sendable (_ transaction: UITransaction) -> Void, - apply: Bool = true, + perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void ) { - if apply { action(.current) } + operation(.current) withPerceptionTracking { tracking(.current) @@ -306,8 +305,7 @@ private func onChange( task(.current) { onChange( of: tracking, - perform: action, - apply: true, + perform: operation, task: task ) } From c058a10d2e09208312093dc7da4e8c1117afc740 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Tue, 20 May 2025 15:27:52 +0200 Subject: [PATCH 03/11] Fix strong token capture in `observe(_:onChange:task)` --- Sources/SwiftNavigation/Observe.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index a5dd548a3..d1fbb4568 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -247,7 +247,10 @@ func observe( ) -> ObserveToken { let token = ObserveToken() SwiftNavigation.onChange( - of: tracking, + of: { [weak token] transaction in + guard let token, !token.isCancelled else { return } + tracking(transaction) + }, perform: { [weak token] transaction in guard let token, From ef2926b744b753077efe0a8a85134f4a33f64829 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 24 Sep 2025 12:50:29 +0200 Subject: [PATCH 04/11] feat: Fixes for expose-observe-function - Fix Xcode26 warnings related to redundant use of @_inheritActorContext - Fix NSObject.observe --- .../SwiftNavigation/NSObject+Observe.swift | 12 +- Sources/SwiftNavigation/Observe.swift | 159 +++++++++--------- 2 files changed, 90 insertions(+), 81 deletions(-) diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index 8ea3f0e9a..5007650b5 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -121,10 +121,14 @@ /// - Returns: A cancellation token. @discardableResult public func observe( - _ tracking: @escaping @MainActor @Sendable () -> Void, + _ context: @escaping @MainActor @Sendable () -> Void, onChange apply: @escaping @MainActor @Sendable () -> Void ) -> ObserveToken { - observe { _ in apply() } + observe { _ in + context() + } onChange: { _ in + apply() + } } /// Observe access to properties of an observable (or perceptible) object. @@ -136,12 +140,12 @@ /// - Returns: A cancellation token. @discardableResult public func observe( - _ tracking: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void, + _ context: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void, onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { let token = SwiftNavigation.observe { transaction in MainActor._assumeIsolated { - tracking(transaction) + context(transaction) } } onChange: { transaction in MainActor._assumeIsolated { diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index d1fbb4568..e06e6c12f 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -55,9 +55,10 @@ import ConcurrencyExtras /// - apply: A closure that contains properties to track. /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. + @inlinable public func observe( isolation: (any Actor)? = #isolation, - @_inheritActorContext _ apply: @escaping @Sendable () -> Void + _ apply: @escaping @Sendable () -> Void ) -> ObserveToken { observe(isolation: isolation) { _ in apply() } } @@ -116,10 +117,11 @@ import ConcurrencyExtras /// - onChange: A closure that is triggered after some tracked property has changed /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. + @inlinable public func observe( isolation: (any Actor)? = #isolation, - @_inheritActorContext _ tracking: @escaping @Sendable () -> Void, - @_inheritActorContext onChange apply: @escaping @Sendable () -> Void + _ tracking: @escaping @Sendable () -> Void, + onChange apply: @escaping @Sendable () -> Void ) -> ObserveToken { observe( isolation: isolation, @@ -137,43 +139,63 @@ import ConcurrencyExtras /// - apply: A closure that contains properties to track. /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. + @inlinable public func observe( isolation: (any Actor)? = #isolation, - @_inheritActorContext _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void + _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { - let actor = ActorProxy(base: isolation) return observe( + isolation: isolation, apply, - task: { transaction, operation in - Task { - await actor.perform { - operation() - } - } - } + onChange: apply ) } -/// Tracks access to properties of an observable model. -/// -/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``. -/// -/// - Parameters: -/// - isolation: The isolation of the observation. -/// - tracking: A closure that contains properties to track. -/// - onChange: A closure that is triggered after some tracked property has changed -/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token -/// is deallocated. + /// Tracks access to properties of an observable model. + /// + /// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``. + /// + /// - Parameters: + /// - isolation: The isolation of the observation. + /// - tracking: A closure that contains properties to track. + /// - onChange: A closure that is triggered after some tracked property has changed + /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token + /// is deallocated. public func observe( isolation: (any Actor)? = #isolation, - @_inheritActorContext _ tracking: @escaping @Sendable (UITransaction) -> Void, - @_inheritActorContext onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void + _ context: @escaping @Sendable (UITransaction) -> Void, + onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void + ) -> ObserveToken { + apply(.current) + + return onChange( + isolation: isolation, + of: context, + perform: apply + ) + } + + /// Tracks access to properties of an observable model. + /// + /// A version of ``observe(isolation:_:onChange:)`` that is handed the current ``UITransaction`` + /// that doesn't have initial application of the operation. Operation block is only called on observed context change. + /// + /// - Parameters: + /// - isolation: The isolation of the observation. + /// - tracking: A closure that contains properties to track. + /// - onChange: A closure that is triggered after some tracked property has changed + /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token + /// is deallocated. + public func onChange( + isolation: (any Actor)? = #isolation, + of context: @escaping @Sendable (UITransaction) -> Void, + perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { let actor = ActorProxy(base: isolation) - return observe( - tracking, - onChange: apply, + return onChange( + of: context, + perform: operation, task: { transaction, operation in Task { await actor.perform { @@ -207,36 +229,15 @@ func observe( Task(operation: $1) } ) -> ObserveToken { - let token = ObserveToken() - onChange( - { [weak token] transaction in - guard - let token, - !token.isCancelled - else { return } - - var perform: @Sendable () -> Void = { apply(transaction) } - for key in transaction.storage.keys { - guard let keyType = key.keyType as? any _UICustomTransactionKey.Type - else { continue } - func open(_: K.Type) { - perform = { [perform] in - K.perform(value: transaction[K.self]) { - perform() - } - } - } - open(keyType) - } - perform() - }, + observe( + apply, + onChange: apply, task: task ) - return token } func observe( - _ tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void, + _ context: @escaping @Sendable (_ transaction: UITransaction) -> Void, onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, @@ -244,12 +245,31 @@ func observe( ) -> Void = { Task(operation: $1) } +) -> ObserveToken { + apply(.current) + + return SwiftNavigation.onChange( + of: context, + perform: apply, + task: task + ) +} + +func onChange( + of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, + perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } ) -> ObserveToken { let token = ObserveToken() - SwiftNavigation.onChange( + SwiftNavigation.withRecursivePerceptionTracking( of: { [weak token] transaction in guard let token, !token.isCancelled else { return } - tracking(transaction) + context(transaction) }, perform: { [weak token] transaction in guard @@ -257,7 +277,7 @@ func observe( !token.isCancelled else { return } - var perform: @Sendable () -> Void = { apply(transaction) } + var perform: @Sendable () -> Void = { operation(transaction) } for key in transaction.storage.keys { guard let keyType = key.keyType as? any _UICustomTransactionKey.Type else { continue } @@ -277,37 +297,22 @@ func observe( return token } -private func onChange( - _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void - ) -> Void -) { - withPerceptionTracking { - apply(.current) - } onChange: { - task(.current) { - onChange(apply, task: task) - } - } -} - -private func onChange( - of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void, +private func withRecursivePerceptionTracking( + of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void ) { - operation(.current) - withPerceptionTracking { - tracking(.current) + context(.current) } onChange: { task(.current) { - onChange( - of: tracking, + operation(.current) + + withRecursivePerceptionTracking( + of: context, perform: operation, task: task ) From 189e51a30bd3a41d627f8e6f55935add8c171667 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Thu, 9 Oct 2025 15:39:50 +0200 Subject: [PATCH 05/11] fix: Isolation tests --- Sources/SwiftNavigation/Observe.swift | 79 +++++++++++++++++++-------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index b64e01eda..0f390b1ae 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -142,9 +142,7 @@ import ConcurrencyExtras _observe( apply, task: { transaction, operation in - Task { - await operation() - } + call(operation) } ) } @@ -161,6 +159,7 @@ import ConcurrencyExtras public func observe( @_inheritActorContext _ context: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext onChange apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { _observe( @@ -183,10 +182,12 @@ import ConcurrencyExtras /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. func _observe( - _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( + @_inheritActorContext + _ apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + task: @escaping @isolated(any) @Sendable ( _ transaction: UITransaction, - _ operation: @escaping @Sendable () -> Void + _ operation: @escaping @isolated(any) @Sendable () -> Void ) -> Void = { Task(operation: $1) } @@ -207,11 +208,14 @@ func _observe( /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. func _observe( - _ context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( + @_inheritActorContext + _ context: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + task: @escaping @isolated(any) @Sendable ( _ transaction: UITransaction, - _ operation: @escaping @Sendable () -> Void + _ operation: @escaping @isolated(any) @Sendable () -> Void ) -> Void = { Task(operation: $1) } @@ -222,7 +226,7 @@ func _observe( task: task ) - apply(.current) + callWithUITransaction(.current, apply) return token } @@ -235,9 +239,12 @@ func _observe( /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. func onChange( - of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( + @_inheritActorContext + of context: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + perform operation: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + task: @escaping @isolated(any) @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void = { @@ -248,7 +255,7 @@ func onChange( SwiftNavigation.withRecursivePerceptionTracking( of: { [weak token] transaction in guard let token, !token.isCancelled else { return } - context(transaction) + callWithUITransaction(transaction, context) }, perform: { [weak token] transaction in guard @@ -256,7 +263,7 @@ func onChange( !token.isCancelled else { return } - var perform: @Sendable () -> Void = { operation(transaction) } + var perform: @Sendable () -> Void = { callWithUITransaction(transaction, operation) } for key in transaction.storage.keys { guard let keyType = key.keyType as? any _UICustomTransactionKey.Type else { continue } @@ -277,18 +284,21 @@ func onChange( } private func withRecursivePerceptionTracking( - of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( + @_inheritActorContext + of context: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + perform operation: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + task: @escaping @isolated(any) @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void ) { withPerceptionTracking { - context(.current) + callWithUITransaction(.current, context) } onChange: { - task(.current) { - operation(.current) + callWithUITransaction(.current, task) { + callWithUITransaction(.current, operation) withRecursivePerceptionTracking( of: context, @@ -299,6 +309,31 @@ private func withRecursivePerceptionTracking( } } +@Sendable +private func call(_ f: @escaping @Sendable () -> Void) { + f() +} + +@Sendable +private func callWithUITransaction( + _ transaction: UITransaction, + _ f: @escaping @Sendable (_ transaction: UITransaction) -> Void +) { + f(transaction) +} + +@Sendable +private func callWithUITransaction( + _ transaction: UITransaction, + _ f: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @isolated(any) @Sendable () -> Void + ) -> Void, + _ operation: @escaping @isolated(any) @Sendable () -> Void +) { + f(transaction, operation) +} + /// A token for cancelling observation. /// /// When this token is deallocated it cancels the observation it was associated with. Store this From cfd5b880c0bb1ced57ef5ce86ab1d87f6990bbe1 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Thu, 26 Feb 2026 03:03:13 +0100 Subject: [PATCH 06/11] feat: Value observation --- Sources/SwiftNavigation/Observe.swift | 158 +++++++++++++++++++++----- 1 file changed, 130 insertions(+), 28 deletions(-) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index 9734ac3a5..41927ffea 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -1,6 +1,93 @@ import ConcurrencyExtras #if swift(>=6) +/// Tracks access to properties of an observable model. +/// +/// A version of ``observe(_:onChange:)-(_,(T)->Void)`` that is handed the current ``UITransaction``. +/// +/// - Parameter context: An autoclosure that returns property to track. +/// - Parameter apply: Invoked when the value of a property changes +/// > `onChange` is also invoked on initial call +/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token +/// is deallocated. + public func observe( + @_inheritActorContext + _ context: @escaping @isolated(any) @Sendable @autoclosure () -> T, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable (UITransaction, T) -> Void + ) -> ObserveToken { + _observe( + isolation: context.isolation, + { _ in _assumeNotThrowing(call: context) }, + onChange: { _assumeNotThrowing(call: apply, with: $0, $1) } + ) + } + + /// Tracks access to property of an observable model. + /// + /// This function allows one to minimally observe changes in a model in order to + /// react to those changes. For example, if you had an observable model like so: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// var count = 0 + /// } + /// ``` + /// + /// Then you can use `observe` to observe changes in the model. For example, in UIKit you can + /// update a `UILabel`: + /// + /// ```swift + /// observe(model.count) { [countLabel] value in + /// countLabel.text = "Count: \(value)" + /// } + /// ``` + /// + /// Anytime the `count` property of the model changes the trailing closure will be invoked again, + /// allowing you to update the view. Further, only changes to properties accessed in the trailing + /// closure will be observed. + /// + /// > Note: If you are targeting Apple's older platforms (anything before iOS 17, macOS 14, + /// > tvOS 17, watchOS 10), then you can use our + /// > [Perception](http://github.com/pointfreeco/swift-perception) library to replace Swift's + /// > Observation framework. + /// + /// This function also works on non-Apple platforms, such as Windows, Linux, Wasm, and more. For + /// example, in a Wasm app you could observe changes to the `count` property to update the inner + /// HTML of a tag: + /// + /// ```swift + /// import JavaScriptKit + /// + /// var countLabel = document.createElement("span") + /// _ = document.body.appendChild(countLabel) + /// + /// let token = observe(model.count) { value in + /// countLabel.innerText = .string("Count: \(value)") + /// } + /// ``` + /// + /// And you can also build your own tools on top of `observe`. + /// +/// - Parameter context: An autoclosure that returns property to track. + /// - Parameter apply: Invoked when the value of a property changes + /// > `onChange` is also invoked on initial call + /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token + /// is deallocated. + public func observe( + @_inheritActorContext + _ context: @escaping @isolated(any) @Sendable @autoclosure () -> T, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable (T) -> Void + ) -> ObserveToken { + _observe( + isolation: context.isolation, + { _ in _assumeNotThrowing(call: context) }, + onChange: { _assumeNotThrowing(call: apply, with: $1) } + ) + } + /// Tracks access to properties of an observable model. /// /// This function is a convenient variant of ``observe(_:onChange:)-(()->Void,_)`` that @@ -61,7 +148,7 @@ import ConcurrencyExtras ) -> ObserveToken { _observe( isolation: apply.isolation, - { _ in Result(catching: apply).get() } + { _ in _assumeNotThrowing(call: apply) } ) } @@ -125,8 +212,8 @@ import ConcurrencyExtras ) -> ObserveToken { _observe( isolation: context.isolation, - { _ in Result(catching: context).get() }, - onChange: { _ in Result(catching: apply).get() } + { _ in _assumeNotThrowing(call: context) }, + onChange: { _, _ in _assumeNotThrowing(call: apply) } ) } @@ -165,7 +252,9 @@ import ConcurrencyExtras _observe( isolation: context.isolation, context, - onChange: apply + onChange: { transaction, _ in + _assumeNotThrowing(call: apply, with: transaction) + } ) } #endif @@ -207,13 +296,13 @@ func _observe( /// - Parameter task: The task that wraps recursive observation calls /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. -func _observe( +func _observe( isolation: (any Actor)?, - _ context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void + _ context: @escaping @Sendable (_ transaction: UITransaction) -> T, + onChange apply: @escaping @Sendable (_ transaction: UITransaction, T) -> Void ) -> ObserveToken { let actor = ActorProxy(base: isolation) - let token = onChange( + let observation = onChange( of: context, perform: apply, task: { transaction, operation in @@ -225,8 +314,8 @@ func _observe( } ) - apply(.current) - return token + apply(.current, observation.initialValue) + return observation.token } // MARK: - onChange @@ -285,29 +374,35 @@ func onChange( /// - Parameter task: The task that wraps recursive observation calls /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. -func onChange( - of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, +func onChange( + of context: @escaping @Sendable (_ transaction: UITransaction) -> T, + perform operation: @escaping @Sendable (_ transaction: UITransaction, T) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void = { Task(operation: $1) } -) -> ObserveToken { +) -> (token: ObserveToken, initialValue: T) { let token = ObserveToken() - SwiftNavigation.withRecursivePerceptionTracking( + + // Token is just initialized and strongly held, value is effectively + // runtime-guaranteed + let initialValue: T! = SwiftNavigation.withRecursivePerceptionTracking( of: { [weak token] transaction in - guard let token, !token.isCancelled else { return } - context(transaction) + guard let token, !token.isCancelled else { return nil } + return context(transaction) }, - perform: { [weak token] transaction in + perform: { [weak token] transaction, value in guard let token, + let value, !token.isCancelled else { return } - var perform: @Sendable () -> Void = { operation(transaction) } + let uncheckedSendableValue = UncheckedSendable(value) + + var perform: @Sendable () -> Void = { operation(transaction, uncheckedSendableValue.value) } for key in transaction.storage.keys { guard let keyType = key.keyType as? any _UICustomTransactionKey.Type else { continue } @@ -324,7 +419,7 @@ func onChange( }, task: task ) - return token + return (token, initialValue) } // MARK: - Perception @@ -346,25 +441,23 @@ private func withRecursivePerceptionTracking( } } -private func withRecursivePerceptionTracking( - of context: @escaping @Sendable (_ transaction: UITransaction) -> Void, - perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void, +private func withRecursivePerceptionTracking( + of context: @escaping @Sendable (_ transaction: UITransaction) -> T, + perform operation: @escaping @Sendable (_ transaction: UITransaction, T) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void ) -> Void -) { +) -> T { withPerceptionTracking { context(.current) } onChange: { task(.current) { - operation(.current) - - withRecursivePerceptionTracking( + operation(.current, withRecursivePerceptionTracking( of: context, perform: operation, task: task - ) + )) } } } @@ -448,3 +541,12 @@ private actor ActorProxy { operation() } } + +// MARK: Isolation workaround + +private func _assumeNotThrowing( + call body: (repeat each Arg) throws(Error) -> Output, + with args: repeat each Arg +) -> Output { + try! body(repeat each args) +} From 994a41549166cba4469af1a6c41f083a6d1a0dd2 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Thu, 26 Feb 2026 03:34:54 +0100 Subject: [PATCH 07/11] feat: Value observation for NSObjects --- .../SwiftNavigation/NSObject+Observe.swift | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index 175b47fd9..f1eb3e8a2 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -1,6 +1,7 @@ #if canImport(ObjectiveC) import Dispatch import ObjectiveC + import ConcurrencyExtras @MainActor extension NSObject { @@ -133,6 +134,47 @@ } } + /// Observe access to a property of an observable (or perceptible) object. + /// + /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is passed updated value. + /// + /// - Parameter tracking: A closure that contains properties to track + /// - Parameter onChange: Invoked when the value of a property changes + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ context: @escaping @MainActor @Sendable @autoclosure () -> T, + onChange apply: @escaping @MainActor @Sendable (T) -> Void + ) -> ObserveToken { + observe(context()) { apply($1) } + } + + /// Observe access to a property of an observable (or perceptible) objectt. + /// + /// A version of ``observe(_:onChange:)-(_,(T)->Void)`` that is passed the current transaction + /// alongside.updated value + /// + /// - Parameter context: An access to property to track + /// - Parameter onChange: Invoked when the value of a property changes + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ context: @escaping @MainActor @Sendable @autoclosure () -> T, + onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction, T) -> Void + ) -> ObserveToken { + let token = SwiftNavigation._observe(isolation: MainActor.shared) { _ in + MainActor._assumeIsolated { + UncheckedSendable(context()) + } + } onChange: { transaction, value in + MainActor._assumeIsolated { + apply(transaction, value.wrappedValue) + } + } + tokens.append(token) + return token + } + /// Observe access to properties of an observable (or perceptible) object. /// /// A version of ``observe(_:)-(()->Void)`` that is passed the current transaction. @@ -157,7 +199,7 @@ /// /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is passed the current transaction. /// - /// - Parameter tracking: A closure that contains properties to track + /// - Parameter context: A closure that contains properties to track /// - Parameter onChange: Invoked when the value of a property changes /// - Returns: A cancellation token. @discardableResult @@ -169,7 +211,7 @@ MainActor._assumeIsolated { context(transaction) } - } onChange: { transaction in + } onChange: { transaction, _ in MainActor._assumeIsolated { apply(transaction) } From 5937275e3f434ea931ec3052df02856576b2c882 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Thu, 26 Feb 2026 04:56:38 +0100 Subject: [PATCH 08/11] feat: Update ObserveTests --- .../ObserveTests/ObserveTests+Nesting.swift | 353 ++++++++++++++---- 1 file changed, 285 insertions(+), 68 deletions(-) diff --git a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift index b616bc2bc..a1dfad861 100644 --- a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift @@ -6,21 +6,27 @@ import ConcurrencyExtras #if !os(WASI) class NestingObserveTests: XCTestCase { @MainActor - func testNestedObservationMisuse() async { + func testNestedObservationMisuse() async throws { // ParentObject and ChildObject models // do not use scoped observation in these tests. // This results in redundant updates. // The issue is related to nested unscoped `observe` calls // it is expected behavior for this kind of API misuse - let object = ParentObject() - let model = ParentObject.Model() + let tracker = MockTracker() + let object = ParentObject(tracker: tracker) + let model = ParentObject.Model(tracker: tracker) + + tracker.entries.removeAll() + + await Task.yield() - MockTracker.shared.entries.withValue { $0.removeAll() } object.bind(model) + await Task.yield() + XCTAssertEqual( - MockTracker.shared.entries.withValue { $0.map(\.label) }, + tracker.entries, [ "ParentObject.bind", "ParentObject.valueUpdate 0", @@ -32,33 +38,41 @@ import ConcurrencyExtras ] ) - MockTracker.shared.entries.withValue { $0.removeAll() } + tracker.entries.removeAll() + + await Task.yield() + model.child.value = 1 await Task.yield() // NOTE: Scoped update won't trigger update of the parent - // Also MockTracker entries are flaky, tho it triggers parent updates consistently - XCTAssertEqual( - MockTracker.shared.entries.withValue { $0.map(\.label) }.contains("ParentObject.childUpdate"), - true + // The test seems flaky, so only checking for expected redundant "childUpdate" + XCTAssert( + tracker.entries.contains("ParentObject.childUpdate") ) } @MainActor - func testNestedObservation() async { + func testNestedObservation() async throws { // ParentObject and ChildObject models // use scoped observation in these tests // to avoid redundant updates - let object = ScopedParentObject() - let model = ScopedParentObject.Model() + let tracker = MockTracker() + let object = ScopedParentObject(tracker: tracker) + let model = ScopedParentObject.Model(tracker: tracker) + + tracker.entries.removeAll() + + await Task.yield() - MockTracker.shared.entries.withValue { $0.removeAll() } object.bind(model) + await Task.yield() + XCTAssertEqual( - MockTracker.shared.entries.withValue { $0.map(\.label) }, + tracker.entries, [ "ParentObject.bind", "ParentObject.valueUpdate 0", @@ -70,13 +84,16 @@ import ConcurrencyExtras ] ) - MockTracker.shared.entries.withValue { $0.removeAll() } + tracker.entries.removeAll() + + await Task.yield() + model.child.value = 1 await Task.yield() XCTAssertEqual( - MockTracker.shared.entries.withValue { $0.map(\.label) }, + tracker.entries, [ "ChildObject.Model.value.didSet 1", "ChildObject.valueUpdate 1", @@ -84,6 +101,67 @@ import ConcurrencyExtras ] ) } + + @MainActor + func testAutoclosureObservation() async throws { + let tracker = MockTracker() + let model = ReadTrackingModel(tracker: tracker) + var token: ObserveToken? + + tracker.entries.removeAll() + + token = observe { _ = model.value } onChange: { + tracker.track("didSet \(model.value)") + } + + await Task.yield() + + model.value += 1 + + await Task.yield() + + XCTAssertEqual( + tracker.entries, + [ + "ReadTrackingModel.value.get 0", // observe context + "ReadTrackingModel.value.get 0", // initial onChange call + "didSet 0", // initial onChange handler + "ReadTrackingModel.value.get 0", // "0+" read + "ReadTrackingModel.value.set 1", // "+1" write + "ReadTrackingModel.value.get 1", // recursive tracking context + "ReadTrackingModel.value.get 1", // recursive tracking initial onChange call + "didSet 1", // recursive tracking onChange handler + ] + ) + + token?.cancel() + model.value = 0 + tracker.entries.removeAll() + + await Task.yield() + + token = observe(model.value) { _, value in + tracker.track("didSet \(value)") + } + + model.value += 1 + + await Task.yield() + + XCTAssertEqual( + tracker.entries, + [ + "ReadTrackingModel.value.get 0", // observe context + // "ReadTrackingModel.value.get 0", // initial onChange call doesn't cause additional read + "didSet 0", // onChange handler + "ReadTrackingModel.value.get 0", // "0+" read + "ReadTrackingModel.value.set 1", // "+1" write + "ReadTrackingModel.value.get 1", // recursive tracking context + // "ReadTrackingModel.value.get 1", // recursive tracking onChange call doesn't cause additional read + "didSet 1", // recursive tracking onChange handler + ] + ) + } } // MARK: - Mocks @@ -91,24 +169,50 @@ import ConcurrencyExtras // MARK: Unscoped fileprivate class ParentObject: @unchecked Sendable { - var tokens: Set = [] - var child: ChildObject = .init() + private let tracker: MockTracker + private var tokens: Set = [] + + var value: Int { + didSet { tracker.track("ParentObject.value.didSet \(value)") } + } - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet \(value)") } + var child: ChildObject { + didSet { tracker.track("ParentObject.child.didSet") } + } + + convenience init( + tracker: MockTracker, + value: Int = 0, + childValue: Int = 0 + ) { + self.init( + tracker: tracker, + value: value, + child: .init(tracker: tracker, value: childValue) + ) + } + + init( + tracker: MockTracker, + value: Int = 0, + child: ChildObject + ) { + self.tracker = tracker + self.child = child + self.value = value } func bind(_ model: Model) { - MockTracker.shared.track((), with: "ParentObject.bind") + tracker.track("ParentObject.bind") // Observe calls are not scoped tokens = [ - observe { [weak self] in - MockTracker.shared.track((), with: "ParentObject.valueUpdate \(model.value)") + observe { [weak self, tracker] in + tracker.track("ParentObject.valueUpdate \(model.value)") self?.value = model.value }, - observe { [weak self] in - MockTracker.shared.track((), with: "ParentObject.childUpdate") + observe { [weak self, tracker] in + tracker.track("ParentObject.childUpdate") self?.child.bind(model.child) } ] @@ -116,30 +220,60 @@ import ConcurrencyExtras @Perceptible class Model: @unchecked Sendable { - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet \(value)") } + private let tracker: MockTracker + + var value: Int { + didSet { tracker.track("ParentObject.Model.value.didSet \(value)") } + } + + var child: ChildObject.Model { + didSet { tracker.track("ParentObject.Model.child.didSet") } + } + + convenience init( + tracker: MockTracker, + value: Int = 0, + childValue: Int = 0 + ) { + self.init( + tracker: tracker, + value: value, + child: .init(tracker: tracker, value: value) + ) } - var child: ChildObject.Model = .init() { - didSet { MockTracker.shared.track(value, with: "ParentObject.Model.child.didSet") } + init( + tracker: MockTracker, + value: Int = 0, + child: ChildObject.Model + ) { + self.tracker = tracker + self.value = value + self.child = child } } } fileprivate class ChildObject: @unchecked Sendable { - var tokens: Set = [] + private let tracker: MockTracker + private var tokens: Set = [] + + var value: Int { + didSet { tracker.track("ChildObject.value.didSet \(value)") } + } - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet \(value)") } + init(tracker: MockTracker, value: Int = 0) { + self.tracker = tracker + self.value = value } func bind(_ model: Model) { - MockTracker.shared.track((), with: "ChildObject.bind") + tracker.track("ChildObject.bind") // Observe calls are not scoped tokens = [ - observe { [weak self] in - MockTracker.shared.track((), with: "ChildObject.valueUpdate \(model.value)") + observe { [weak self, tracker] in + tracker.track("ChildObject.valueUpdate \(model.value)") self?.value = model.value } ] @@ -147,8 +281,15 @@ import ConcurrencyExtras @Perceptible class Model: @unchecked Sendable { + private let tracker: MockTracker + var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet \(value)") } + didSet { tracker.track("ChildObject.Model.value.didSet \(value)") } + } + + init(tracker: MockTracker, value: Int = 0) { + self.tracker = tracker + self.value = value } } } @@ -156,24 +297,50 @@ import ConcurrencyExtras // MARK: - Scoped fileprivate class ScopedParentObject: @unchecked Sendable { - var tokens: Set = [] - var child: ScopedChildObject = .init() + private let tracker: MockTracker + private var tokens: Set = [] - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet \(value)") } + var value: Int { + didSet { tracker.track("ParentObject.value.didSet \(value)") } + } + + var child: ScopedChildObject { + didSet { tracker.track("ParentObject.child.didSet") } + } + + convenience init( + tracker: MockTracker, + value: Int = 0, + childValue: Int = 0 + ) { + self.init( + tracker: tracker, + value: value, + child: .init(tracker: tracker, value: childValue) + ) + } + + init( + tracker: MockTracker, + value: Int = 0, + child: ScopedChildObject + ) { + self.tracker = tracker + self.value = value + self.child = child } func bind(_ model: Model) { - MockTracker.shared.track((), with: "ParentObject.bind") + tracker.track("ParentObject.bind") // Observe calls are scoped tokens = [ - observe { _ = model.value } onChange: { [weak self] in - MockTracker.shared.track((), with: "ParentObject.valueUpdate \(model.value)") + observe { _ = model.value } onChange: { [weak self, tracker] in + tracker.track("ParentObject.valueUpdate \(model.value)") self?.value = model.value }, - observe { _ = model.child } onChange: { [weak self] in - MockTracker.shared.track((), with: "ParentObject.childUpdate") + observe { _ = model.child } onChange: { [weak self, tracker] in + tracker.track("ParentObject.childUpdate") self?.child.bind(model.child) } ] @@ -181,30 +348,60 @@ import ConcurrencyExtras @Perceptible class Model: @unchecked Sendable { - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet \(value)") } + private let tracker: MockTracker + + var value: Int { + didSet { tracker.track("ParentObject.Model.value.didSet \(value)") } + } + + var child: ScopedChildObject.Model { + didSet { tracker.track("ParentObject.Model.child.didSet") } + } + + convenience init( + tracker: MockTracker, + value: Int = 0, + childValue: Int = 0 + ) { + self.init( + tracker: tracker, + value: value, + child: .init(tracker: tracker, value: value) + ) } - var child: ScopedChildObject.Model = .init() { - didSet { MockTracker.shared.track(value, with: "ParentObject.Model.child.didSet") } + init( + tracker: MockTracker, + value: Int = 0, + child: ScopedChildObject.Model + ) { + self.tracker = tracker + self.value = value + self.child = child } } } fileprivate class ScopedChildObject: @unchecked Sendable { - var tokens: Set = [] + private let tracker: MockTracker + private var tokens: Set = [] - var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet \(value)") } + var value: Int { + didSet { tracker.track("ChildObject.value.didSet \(value)") } + } + + init(tracker: MockTracker, value: Int = 0) { + self.tracker = tracker + self.value = value } func bind(_ model: Model) { - MockTracker.shared.track((), with: "ChildObject.bind") + tracker.track("ChildObject.bind") // Observe calls not scoped tokens = [ - observe { _ = model.value } onChange: { [weak self] in - MockTracker.shared.track((), with: "ChildObject.valueUpdate \(model.value)") + observe { _ = model.value } onChange: { [weak self, tracker] in + tracker.track("ChildObject.valueUpdate \(model.value)") self?.value = model.value } ] @@ -212,32 +409,52 @@ import ConcurrencyExtras @Perceptible class Model: @unchecked Sendable { + private let tracker: MockTracker + var value: Int = 0 { - didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet \(value)") } + didSet { tracker.track("ChildObject.Model.value.didSet \(value)") } + } + + init(tracker: MockTracker, value: Int = 0) { + self.tracker = tracker + self.value = value } } } - // MARK: Tracker + @Perceptible + fileprivate class ReadTrackingModel: @unchecked Sendable { + private let tracker: MockTracker + private var _value: Int - fileprivate final class MockTracker: @unchecked Sendable { - static let shared = MockTracker() + init(tracker: MockTracker, value: Int = 0) { + self.tracker = tracker + self._value = value + } - struct Entry { - var label: String - var value: Any + var value: Int { + get { + tracker.track("ReadTrackingModel.value.get \(_value)") + return _value + } + set { + tracker.track("ReadTrackingModel.value.set \(newValue)") + _value = newValue + } } + } - var entries: LockIsolated<[Entry]> = .init([]) + // MARK: Tracker + + fileprivate final class MockTracker: @unchecked Sendable { + var entries: [String] = [] init() {} func track( - _ value: Any, - with label: String + _ entry: String ) { - let uncheckedSendable = UncheckedSendable(value) - entries.withValue { $0.append(.init(label: label, value: uncheckedSendable.value)) } + entries.append(entry) } } #endif From f105a8551787d9680b09a23090bfdafb6e060c3a Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 6 Mar 2026 11:16:43 +0100 Subject: [PATCH 09/11] fix: Update doc comments --- Sources/SwiftNavigation/NSObject+Observe.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index f1eb3e8a2..bb6a1fcd2 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -119,8 +119,8 @@ /// observable model in order to populate your view, and also automatically track changes to /// any fields accessed in the tracking parameter so that the view is always up-to-date. /// - /// - Parameter tracking: A closure that contains properties to track - /// - Parameter onChange: Invoked when the value of a property changes + /// - Parameter context: A closure that contains properties to track + /// - Parameter apply: Invoked when the value of a property changes /// - Returns: A cancellation token. @discardableResult public func observe( @@ -138,8 +138,8 @@ /// /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is passed updated value. /// - /// - Parameter tracking: A closure that contains properties to track - /// - Parameter onChange: Invoked when the value of a property changes + /// - Parameter context: A closure that contains properties to track + /// - Parameter apply: Invoked when the value of a property changes /// - Returns: A cancellation token. @discardableResult public func observe( From 52c254a1ca99c66006417e6aba5e5321ee5b2cd3 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 6 Mar 2026 11:24:55 +0100 Subject: [PATCH 10/11] feat: Invert observe arguments order --- Sources/SwiftNavigation/Observe.swift | 14 +++++++------- .../ObserveTests/ObserveTests+Nesting.swift | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index 41927ffea..a4e0a54a8 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -14,7 +14,7 @@ import ConcurrencyExtras @_inheritActorContext _ context: @escaping @isolated(any) @Sendable @autoclosure () -> T, @_inheritActorContext - onChange apply: @escaping @isolated(any) @Sendable (UITransaction, T) -> Void + onChange apply: @escaping @isolated(any) @Sendable (T, UITransaction) -> Void ) -> ObserveToken { _observe( isolation: context.isolation, @@ -84,7 +84,7 @@ import ConcurrencyExtras _observe( isolation: context.isolation, { _ in _assumeNotThrowing(call: context) }, - onChange: { _assumeNotThrowing(call: apply, with: $1) } + onChange: { value, _ in _assumeNotThrowing(call: apply, with: value) } ) } @@ -252,7 +252,7 @@ import ConcurrencyExtras _observe( isolation: context.isolation, context, - onChange: { transaction, _ in + onChange: { _, transaction in _assumeNotThrowing(call: apply, with: transaction) } ) @@ -299,7 +299,7 @@ func _observe( func _observe( isolation: (any Actor)?, _ context: @escaping @Sendable (_ transaction: UITransaction) -> T, - onChange apply: @escaping @Sendable (_ transaction: UITransaction, T) -> Void + onChange apply: @escaping @Sendable (T, _ transaction: UITransaction) -> Void ) -> ObserveToken { let actor = ActorProxy(base: isolation) let observation = onChange( @@ -314,7 +314,7 @@ func _observe( } ) - apply(.current, observation.initialValue) + apply(observation.initialValue, .current) return observation.token } @@ -376,7 +376,7 @@ func onChange( /// is deallocated. func onChange( of context: @escaping @Sendable (_ transaction: UITransaction) -> T, - perform operation: @escaping @Sendable (_ transaction: UITransaction, T) -> Void, + perform operation: @escaping @Sendable (T, _ transaction: UITransaction) -> Void, task: @escaping @Sendable ( _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void @@ -402,7 +402,7 @@ func onChange( let uncheckedSendableValue = UncheckedSendable(value) - var perform: @Sendable () -> Void = { operation(transaction, uncheckedSendableValue.value) } + var perform: @Sendable () -> Void = { operation(uncheckedSendableValue.value, transaction) } for key in transaction.storage.keys { guard let keyType = key.keyType as? any _UICustomTransactionKey.Type else { continue } diff --git a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift index a1dfad861..773c4d9cd 100644 --- a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift @@ -140,7 +140,7 @@ import ConcurrencyExtras await Task.yield() - token = observe(model.value) { _, value in + token = observe(model.value) { value, _ in tracker.track("didSet \(value)") } From 07b5cdb8401c883b27e626c65e73189456027d9a Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 6 Mar 2026 12:46:13 +0100 Subject: [PATCH 11/11] feat: Use reference-based observation instead of @autoclosure --- .../SwiftNavigation/NSObject+Observe.swift | 32 ++++++++------- Sources/SwiftNavigation/Observe.swift | 40 ++++++++++--------- .../ObserveTests/ObserveTests+Nesting.swift | 2 +- 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index bb6a1fcd2..c4c68cb68 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -136,17 +136,19 @@ /// Observe access to a property of an observable (or perceptible) object. /// - /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is passed updated value. + /// A version of ``observe(_:_:onChange:)-(()->Void,_)`` that is passed updated value. /// - /// - Parameter context: A closure that contains properties to track + /// - Parameter object: Observable object to derive observation from. + /// - Parameter context: Access to a property to track. /// - Parameter apply: Invoked when the value of a property changes /// - Returns: A cancellation token. @discardableResult - public func observe( - _ context: @escaping @MainActor @Sendable @autoclosure () -> T, - onChange apply: @escaping @MainActor @Sendable (T) -> Void + public func observe( + _ object: Object, + _ context: @escaping @MainActor @Sendable (Object) -> Value, + onChange apply: @escaping @MainActor @Sendable (Value) -> Void ) -> ObserveToken { - observe(context()) { apply($1) } + observe(object, context) { value, _ in apply(value) } } /// Observe access to a property of an observable (or perceptible) objectt. @@ -154,21 +156,23 @@ /// A version of ``observe(_:onChange:)-(_,(T)->Void)`` that is passed the current transaction /// alongside.updated value /// - /// - Parameter context: An access to property to track + /// - Parameter object: Observable object to derive observation from. + /// - Parameter context: Access to a property to track. /// - Parameter onChange: Invoked when the value of a property changes /// - Returns: A cancellation token. @discardableResult - public func observe( - _ context: @escaping @MainActor @Sendable @autoclosure () -> T, - onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction, T) -> Void + public func observe( + _ object: Object, + _ context: @escaping @MainActor @Sendable (Object) -> Value, + onChange apply: @escaping @MainActor @Sendable (Value, _ transaction: UITransaction) -> Void ) -> ObserveToken { let token = SwiftNavigation._observe(isolation: MainActor.shared) { _ in MainActor._assumeIsolated { - UncheckedSendable(context()) + UncheckedSendable(context(object)) } - } onChange: { transaction, value in + } onChange: { value, transaction in MainActor._assumeIsolated { - apply(transaction, value.wrappedValue) + apply(value.wrappedValue, transaction) } } tokens.append(token) @@ -211,7 +215,7 @@ MainActor._assumeIsolated { context(transaction) } - } onChange: { transaction, _ in + } onChange: { _, transaction in MainActor._assumeIsolated { apply(transaction) } diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index a4e0a54a8..c155d2420 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -1,24 +1,26 @@ import ConcurrencyExtras #if swift(>=6) -/// Tracks access to properties of an observable model. -/// -/// A version of ``observe(_:onChange:)-(_,(T)->Void)`` that is handed the current ``UITransaction``. -/// -/// - Parameter context: An autoclosure that returns property to track. -/// - Parameter apply: Invoked when the value of a property changes -/// > `onChange` is also invoked on initial call -/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token -/// is deallocated. - public func observe( + /// Tracks access to properties of an observable model. + /// + /// A version of ``observe(_:_:onChange:)-(_,(T)->Void)`` that is handed the current ``UITransaction``. + /// + /// - Parameter object: Observable object to derive observation from. + /// - Parameter context: Access to a property to track. + /// - Parameter apply: Invoked when the value of a property changes + /// > `onChange` is also invoked on initial call + /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token + /// is deallocated. + public func observe( + object: Object, @_inheritActorContext - _ context: @escaping @isolated(any) @Sendable @autoclosure () -> T, + _ context: @escaping @isolated(any) @Sendable (Object) -> Value, @_inheritActorContext - onChange apply: @escaping @isolated(any) @Sendable (T, UITransaction) -> Void + onChange apply: @escaping @isolated(any) @Sendable (Value, UITransaction) -> Void ) -> ObserveToken { _observe( isolation: context.isolation, - { _ in _assumeNotThrowing(call: context) }, + { _ in _assumeNotThrowing(call: context, with: object) }, onChange: { _assumeNotThrowing(call: apply, with: $0, $1) } ) } @@ -70,20 +72,22 @@ import ConcurrencyExtras /// /// And you can also build your own tools on top of `observe`. /// -/// - Parameter context: An autoclosure that returns property to track. + /// - Parameter object: Observable object to derive observation from. + /// - Parameter context: Access to a property to track. /// - Parameter apply: Invoked when the value of a property changes /// > `onChange` is also invoked on initial call /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. - public func observe( + public func observe( + object: Object, @_inheritActorContext - _ context: @escaping @isolated(any) @Sendable @autoclosure () -> T, + _ context: @escaping @isolated(any) @Sendable (Object) -> Value, @_inheritActorContext - onChange apply: @escaping @isolated(any) @Sendable (T) -> Void + onChange apply: @escaping @isolated(any) @Sendable (Value) -> Void ) -> ObserveToken { _observe( isolation: context.isolation, - { _ in _assumeNotThrowing(call: context) }, + { _ in _assumeNotThrowing(call: context, with: object) }, onChange: { value, _ in _assumeNotThrowing(call: apply, with: value) } ) } diff --git a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift index 773c4d9cd..cb8ef25af 100644 --- a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift @@ -140,7 +140,7 @@ import ConcurrencyExtras await Task.yield() - token = observe(model.value) { value, _ in + token = observe(model, \.value) { value in tracker.track("didSet \(value)") }