diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index 4563ff7e8..175b47fd9 100644 --- a/Sources/SwiftNavigation/NSObject+Observe.swift +++ b/Sources/SwiftNavigation/NSObject+Observe.swift @@ -11,7 +11,7 @@ /// any accessed fields so that the view is always up-to-date. /// /// It is most useful when dealing with non-SwiftUI views, such as UIKit views and controller. - /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all + /// You can invoke the ``observe(_:)-(()->Void)`` method a single time in the `viewDidLoad` and update all /// the view elements: /// /// ```swift @@ -37,7 +37,7 @@ /// ever mutated, this trailing closure will be called again, allowing us to update the view /// again. /// - /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your + /// Generally speaking you can usually have a single ``observe(_:)-(()->Void)`` in the entry point of your /// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI /// components to update: /// @@ -64,7 +64,7 @@ /// a label or the `isHidden` of a button. /// /// However, if there is heavy work you need to perform when state changes, then it is best to - /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or + /// put that in its own ``observe(_:)-(()->Void)``. For example, if you needed to reload a table view or /// collection view when a collection changes: /// /// ```swift @@ -106,13 +106,36 @@ /// of a property changes. /// - Returns: A cancellation token. @discardableResult - public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObserveToken { + public func observe( + _ 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. + /// 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( + _ context: @escaping @MainActor @Sendable () -> Void, + onChange apply: @escaping @MainActor @Sendable () -> Void + ) -> ObserveToken { + observe { _ in + context() + } onChange: { _ in + apply() + } + } + + /// Observe access to properties of an observable (or perceptible) object. + /// + /// A version of ``observe(_:)-(()->Void)`` that is passed the current transaction. /// /// - Parameter apply: A closure that contains properties to track and is invoked when the value /// of a property changes. @@ -121,13 +144,34 @@ public func observe( _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { - let token = SwiftNavigation._observe { transaction in + let token = SwiftNavigation._observe(isolation: MainActor.shared) { 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(_:onChange:)-(()->Void,_)`` 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( + _ context: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void, + onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + ) -> ObserveToken { + let token = SwiftNavigation._observe(isolation: MainActor.shared) { transaction in + MainActor._assumeIsolated { + context(transaction) + } + } onChange: { transaction in + MainActor._assumeIsolated { + apply(transaction) } } tokens.append(token) diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index 453674475..9734ac3a5 100644 --- a/Sources/SwiftNavigation/Observe.swift +++ b/Sources/SwiftNavigation/Observe.swift @@ -3,7 +3,9 @@ import ConcurrencyExtras #if swift(>=6) /// Tracks access to properties of an observable model. /// - /// This function allows one to minimally observe changes in a model in order to + /// This function is a convenient variant of ``observe(_:onChange:)-(()->Void,_)`` that + /// combines tracking context and onChange handler in one `apply` argument + /// and 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 @@ -50,9 +52,7 @@ import ConcurrencyExtras /// /// And you can also build your own tools on top of `observe`. /// - /// - Parameters: - /// - isolation: The isolation of the observation. - /// - apply: A closure that contains properties to track. + /// - Parameter apply: A closure that contains properties to track. /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. public func observe( @@ -67,11 +67,74 @@ import ConcurrencyExtras /// Tracks access to properties of an observable model. /// - /// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``. + /// 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] in model.count } onChange: { [countLabel, model] in + /// 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 /// - /// - Parameters: - /// - isolation: The isolation of the observation. - /// - apply: A closure that contains properties to track. + /// 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`. + /// + /// - Parameter context: A closure that contains properties 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 () -> Void, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable () -> Void + ) -> ObserveToken { + _observe( + isolation: context.isolation, + { _ in Result(catching: context).get() }, + onChange: { _ in Result(catching: apply).get() } + ) + } + + /// Tracks access to properties of an observable model. + /// + /// A version of ``observe(_:)-(()->Void)`` that is handed the current ``UITransaction``. + /// + /// - Parameter apply: A closure that contains properties to track. /// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token /// is deallocated. public func observe( @@ -83,14 +146,46 @@ import ConcurrencyExtras apply ) } + + /// Tracks access to properties of an observable model. + /// + /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is handed the current ``UITransaction``. + /// + /// - Parameter context: A closure that contains properties 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 (_ transaction: UITransaction) -> Void, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void + ) -> ObserveToken { + _observe( + isolation: context.isolation, + context, + onChange: apply + ) + } #endif +// MARK: - _observe +// Actual isolation is guaranteed here + +/// Observes changes in given context +/// +/// - Parameter apply: Invoked when a change occurs in observed context +/// > `apply` is also invoked on initial call +/// - 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( isolation: (any Actor)?, _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void ) -> ObserveToken { let actor = ActorProxy(base: isolation) - return _observe( + let token = onChange( apply, task: { transaction, operation in Task { @@ -100,17 +195,62 @@ func _observe( } } ) + + return token } +/// Observes changes in given context +/// +/// - Parameter context: Observed context +/// - Parameter apply: Invoked when a change occurs in observed context +/// > `onChange` is also invoked on initial call +/// - 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( + isolation: (any Actor)?, + _ context: @escaping @Sendable (_ transaction: UITransaction) -> Void, + onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void +) -> ObserveToken { + let actor = ActorProxy(base: isolation) + let token = onChange( + of: context, + perform: apply, + task: { transaction, operation in + Task { + await actor.perform { + operation() + } + } + } + ) + + apply(.current) + return token +} + +// MARK: - onChange +// UITransaction & cancellation integration to recursive perception tracking + +/// Observes changes in given context +/// +/// - Parameter context: Observed context +/// - Parameter operation: Invoked when a change occurs in observed context +/// > `operation` is not invoked on initial call +/// - 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( _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: - @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void - ) -> Void + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } ) -> ObserveToken { let token = ObserveToken() - onChange( + SwiftNavigation.withRecursivePerceptionTracking( { [weak token] transaction in guard let token, @@ -137,22 +277,100 @@ func _observe( return token } -private func onChange( +/// Observes changes in given context +/// +/// - Parameter context: Observed context +/// - Parameter operation: Invoked when a change occurs in observed context +/// > `operation` is not invoked on initial call +/// - 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, + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } +) -> ObserveToken { + let token = ObserveToken() + SwiftNavigation.withRecursivePerceptionTracking( + of: { [weak token] transaction in + guard let token, !token.isCancelled else { return } + context(transaction) + }, + perform: { [weak token] transaction in + guard + let token, + !token.isCancelled + else { return } + + var perform: @Sendable () -> Void = { operation(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 +} + +// MARK: - Perception +// Low level functions for recursive perception tracking + +private func withRecursivePerceptionTracking( _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: - @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void - ) -> Void + task: @escaping @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @Sendable () -> Void + ) -> Void ) { withPerceptionTracking { apply(.current) } onChange: { task(.current) { - onChange(apply, task: task) + withRecursivePerceptionTracking(apply, task: task) + } + } +} + +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 +) { + withPerceptionTracking { + context(.current) + } onChange: { + task(.current) { + operation(.current) + + withRecursivePerceptionTracking( + of: context, + perform: operation, + task: task + ) } } } +// MARK: - ObserveToken + /// A token for cancelling observation. /// /// When this token is deallocated it cancels the observation it was associated with. Store this @@ -216,6 +434,8 @@ public final class ObserveToken: Sendable, HashableObject { } } +// MARK: - ActorProxy + private actor ActorProxy { let base: (any Actor)? init(base: (any Actor)?) { diff --git a/Tests/SwiftNavigationTests/IsolationTests.swift b/Tests/SwiftNavigationTests/IsolationTests.swift index a045481aa..4e9fdbfec 100644 --- a/Tests/SwiftNavigationTests/IsolationTests.swift +++ b/Tests/SwiftNavigationTests/IsolationTests.swift @@ -4,7 +4,7 @@ class IsolationTests: XCTestCase { func testIsolationOnMainActor() async throws { - try await Task { @MainActor in + await Task { @MainActor in let model = MainActorModel() var didObserve = false let token = SwiftNavigation.observe { @@ -13,7 +13,26 @@ didObserve = true } model.count += 1 - try await Task.sleep(nanoseconds: 300_000_000) + await Task.yield() + XCTAssertEqual(didObserve, true) + _ = token + } + .value + } + + func testIsolationOnMainActor_derivedObservation() async throws { + await Task { @MainActor in + let model = MainActorModel() + var didObserve = false + let token = SwiftNavigation.observe { + _ = model.count + MainActor.assertIsolated() + } onChange: { + MainActor.assertIsolated() + didObserve = true + } + model.count += 1 + await Task.yield() XCTAssertEqual(didObserve, true) _ = token } @@ -21,7 +40,7 @@ } func testIsolationOnGlobalActor() async throws { - try await Task { @GlobalActorIsolated in + await Task { @GlobalActorIsolated in let model = GlobalActorModel() var didObserve = false let token = SwiftNavigation.observe { @@ -30,7 +49,26 @@ didObserve = true } model.count += 1 - try await Task.sleep(nanoseconds: 300_000_000) + await Task.yield() + XCTAssertEqual(didObserve, true) + _ = token + } + .value + } + + func testIsolationOnGlobalActor_derivedObservation() async throws { + await Task { @GlobalActorIsolated in + let model = GlobalActorModel() + var didObserve = false + let token = SwiftNavigation.observe { + _ = model.count + GlobalActorIsolated.assertIsolated() + } onChange: { + GlobalActorIsolated.assertIsolated() + didObserve = true + } + model.count += 1 + await Task.yield() XCTAssertEqual(didObserve, true) _ = token } diff --git a/Tests/SwiftNavigationTests/LifetimeTests.swift b/Tests/SwiftNavigationTests/LifetimeTests.swift index 20240dd32..e120e23be 100644 --- a/Tests/SwiftNavigationTests/LifetimeTests.swift +++ b/Tests/SwiftNavigationTests/LifetimeTests.swift @@ -27,6 +27,33 @@ } .value } + + func testObserveToken_derivedObservation() async { + await Task { @MainActor in + let model = Model() + var counts = [Int]() + var token: ObserveToken? + do { + token = SwiftNavigation.observe { + _ = model.count + } onChange: { + counts.append(model.count) + } + } + XCTAssertEqual(counts, [0]) + model.count += 1 + await Task.yield() + XCTAssertEqual(counts, [0, 1]) + + _ = token + token = nil + + model.count += 1 + await Task.yield() + XCTAssertEqual(counts, [0, 1]) + } + .value + } } @Perceptible diff --git a/Tests/SwiftNavigationTests/ObserveTests.swift b/Tests/SwiftNavigationTests/ObserveTests.swift deleted file mode 100644 index afafe1731..000000000 --- a/Tests/SwiftNavigationTests/ObserveTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftNavigation -import XCTest - -class ObserveTests: 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 testTokenStorage() async { - var count = 0 - var tokens: Set = [] - observe { - count += 1 - } - .store(in: &tokens) - observe { - count += 1 - } - .store(in: &tokens) - XCTAssertEqual(count, 2) - } - #endif -} diff --git a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift new file mode 100644 index 000000000..b616bc2bc --- /dev/null +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift @@ -0,0 +1,243 @@ +import SwiftNavigation +import Perception +import XCTest +import ConcurrencyExtras + +#if !os(WASI) + class NestingObserveTests: XCTestCase { + @MainActor + func testNestedObservationMisuse() async { + // 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() + + MockTracker.shared.entries.withValue { $0.removeAll() } + object.bind(model) + + XCTAssertEqual( + MockTracker.shared.entries.withValue { $0.map(\.label) }, + [ + "ParentObject.bind", + "ParentObject.valueUpdate 0", + "ParentObject.value.didSet 0", + "ParentObject.childUpdate", + "ChildObject.bind", + "ChildObject.valueUpdate 0", + "ChildObject.value.didSet 0", + ] + ) + + MockTracker.shared.entries.withValue { $0.removeAll() } + 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 + ) + } + + @MainActor + func testNestedObservation() async { + // ParentObject and ChildObject models + // use scoped observation in these tests + // to avoid redundant updates + + let object = ScopedParentObject() + let model = ScopedParentObject.Model() + + MockTracker.shared.entries.withValue { $0.removeAll() } + object.bind(model) + + XCTAssertEqual( + MockTracker.shared.entries.withValue { $0.map(\.label) }, + [ + "ParentObject.bind", + "ParentObject.valueUpdate 0", + "ParentObject.value.didSet 0", + "ParentObject.childUpdate", + "ChildObject.bind", + "ChildObject.valueUpdate 0", + "ChildObject.value.didSet 0", + ] + ) + + MockTracker.shared.entries.withValue { $0.removeAll() } + model.child.value = 1 + + await Task.yield() + + XCTAssertEqual( + MockTracker.shared.entries.withValue { $0.map(\.label) }, + [ + "ChildObject.Model.value.didSet 1", + "ChildObject.valueUpdate 1", + "ChildObject.value.didSet 1" + ] + ) + } + } + + // MARK: - Mocks + + // MARK: Unscoped + + fileprivate class ParentObject: @unchecked Sendable { + var tokens: Set = [] + var child: ChildObject = .init() + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet \(value)") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ParentObject.bind") + + // Observe calls are not scoped + tokens = [ + observe { [weak self] in + MockTracker.shared.track((), with: "ParentObject.valueUpdate \(model.value)") + self?.value = model.value + }, + observe { [weak self] in + MockTracker.shared.track((), with: "ParentObject.childUpdate") + self?.child.bind(model.child) + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet \(value)") } + } + + var child: ChildObject.Model = .init() { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.child.didSet") } + } + } + } + + fileprivate class ChildObject: @unchecked Sendable { + var tokens: Set = [] + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet \(value)") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ChildObject.bind") + + // Observe calls are not scoped + tokens = [ + observe { [weak self] in + MockTracker.shared.track((), with: "ChildObject.valueUpdate \(model.value)") + self?.value = model.value + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet \(value)") } + } + } + } + + // MARK: - Scoped + + fileprivate class ScopedParentObject: @unchecked Sendable { + var tokens: Set = [] + var child: ScopedChildObject = .init() + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet \(value)") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ParentObject.bind") + + // Observe calls are scoped + tokens = [ + observe { _ = model.value } onChange: { [weak self] in + MockTracker.shared.track((), with: "ParentObject.valueUpdate \(model.value)") + self?.value = model.value + }, + observe { _ = model.child } onChange: { [weak self] in + MockTracker.shared.track((), with: "ParentObject.childUpdate") + self?.child.bind(model.child) + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet \(value)") } + } + + var child: ScopedChildObject.Model = .init() { + didSet { MockTracker.shared.track(value, with: "ParentObject.Model.child.didSet") } + } + } + } + + fileprivate class ScopedChildObject: @unchecked Sendable { + var tokens: Set = [] + + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet \(value)") } + } + + func bind(_ model: Model) { + MockTracker.shared.track((), with: "ChildObject.bind") + + // Observe calls not scoped + tokens = [ + observe { _ = model.value } onChange: { [weak self] in + MockTracker.shared.track((), with: "ChildObject.valueUpdate \(model.value)") + self?.value = model.value + } + ] + } + + @Perceptible + class Model: @unchecked Sendable { + var value: Int = 0 { + didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet \(value)") } + } + } + } + + // MARK: Tracker + + fileprivate final class MockTracker: @unchecked Sendable { + static let shared = MockTracker() + + struct Entry { + var label: String + var value: Any + } + + var entries: LockIsolated<[Entry]> = .init([]) + + init() {} + + func track( + _ value: Any, + with label: String + ) { + let uncheckedSendable = UncheckedSendable(value) + entries.withValue { $0.append(.init(label: label, value: uncheckedSendable.value)) } + } + } +#endif diff --git a/Tests/SwiftNavigationTests/ObserveTests/ObserveTests.swift b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests.swift new file mode 100644 index 000000000..ba5f951b2 --- /dev/null +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests.swift @@ -0,0 +1,140 @@ +import SwiftNavigation +import Perception +import XCTest +import ConcurrencyExtras + +class ObserveTests: XCTestCase { + #if swift(>=6) + func testSimpleObserve() async { + await MainActor.run { + var count = 0 + let token = SwiftNavigation.observe { + count = 1 + } + XCTAssertEqual(count, 1) + _ = token + } + } + + func testScopedObserve() async { + await MainActor.run { + var count = 0 + let token = SwiftNavigation.observe { + count = 1 + } onChange: { + count = 2 + } + // onChange is called after invoking the context + XCTAssertEqual(count, 2) + _ = token + } + } + + func testNestedObserve() async { + let a = A() + + nonisolated(unsafe) var value: Int = 0 + nonisolated(unsafe) var outerCount: Int = 0 + nonisolated(unsafe) var innerCount: Int = 0 + nonisolated(unsafe) var innerToken: ObserveToken? + + let outerToken = SwiftNavigation.observe { + outerCount += 1 + let b = a.b + + if innerToken == nil { + innerToken = SwiftNavigation.observe { + // The problem: Outer observe tracks those changes + value = b.value + innerCount += 1 + } + } + } + + // a.b doesn't change here + a.b.value += 1 + + // Those are not enough to perform updates: + // await Task.yeild() + // await Task.megaYeild() + // Falling back to Task.sleep + try? await Task.sleep(nanoseconds: UInt64(0.5 * pow(10, 9))) + + XCTAssertEqual(value, 1) + + // Expected unscoped behavior, that can be optimized + // with observation scoping + XCTAssertEqual(outerCount, 2) // redundant update + XCTAssertEqual(innerCount, 2) // initial value + updated value + _ = outerToken + _ = innerToken + } + + func testScopedNestedObserve() async { + let a = A() + + nonisolated(unsafe) var value: Int = 0 + nonisolated(unsafe) var outerCount: Int = 0 + nonisolated(unsafe) var innerCount: Int = 0 + nonisolated(unsafe) var innerToken: ObserveToken? + + let outerToken = SwiftNavigation.observe { _ = a.b } onChange: { + outerCount += 1 + let b = a.b + + if innerToken == nil { + innerToken = SwiftNavigation.observe { _ = b.value } onChange: { + value = b.value + innerCount += 1 + } + } + } + + a.b.value += 1 + + // Those are not enough to perform updates: + // await Task.yield() + // await Task.megaYield() + // Falling back to Task.sleep + try? await Task.sleep(nanoseconds: UInt64(0.5 * pow(10, 9))) + + XCTAssertEqual(value, 1) + XCTAssertEqual(outerCount, 1) // no redundant updates here + XCTAssertEqual(innerCount, 2) // initial value + updated value + _ = outerToken + _ = innerToken + } + #endif + + #if !os(WASI) + @MainActor + func testTokenStorage() async { + var count = 0 + var tokens: Set = [] + + observe { + count += 1 + } + .store(in: &tokens) + + observe { + count += 1 + } + .store(in: &tokens) + + XCTAssertEqual(count, 2) + } + #endif +} + +@Perceptible +fileprivate class A: @unchecked Sendable { + var b: B = .init() +} + +@Perceptible +fileprivate class B: @unchecked Sendable { + var value: Int = 0 +} + + 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