From a8f3be225a72aac6b286d31033ed091de8147792 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 11 May 2025 15:41:30 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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