diff --git a/Sources/SwiftNavigation/NSObject+Observe.swift b/Sources/SwiftNavigation/NSObject+Observe.swift index 4563ff7e8..d059c6747 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. @@ -134,6 +157,35 @@ 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 { transaction in + MainActor._assumeIsolated { + context(transaction) + } + } onChange: { transaction in + MainActor._assumeIsolated { + apply(transaction) + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } + } + tokens.append(token) + return token + } + fileprivate var tokens: [Any] { get { objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] diff --git a/Sources/SwiftNavigation/Observe.swift b/Sources/SwiftNavigation/Observe.swift index f6c9d90e1..0f390b1ae 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,24 +52,87 @@ 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. + /// is deallocated. + @inlinable public func observe( - @_inheritActorContext _ apply: @escaping @isolated(any) @Sendable () -> Void + @_inheritActorContext + _ apply: @escaping @isolated(any) @Sendable () -> Void ) -> ObserveToken { observe { _ in Result(catching: apply).get() } } /// 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 + /// + /// 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. + @inlinable + public func observe( + @_inheritActorContext + _ context: @escaping @isolated(any) @Sendable () -> Void, + @_inheritActorContext + onChange apply: @escaping @isolated(any) @Sendable () -> Void + ) -> ObserveToken { + observe( + { _ 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``. /// - /// - 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( @@ -76,6 +141,30 @@ import ConcurrencyExtras ) -> ObserveToken { _observe( apply, + task: { transaction, operation in + call(operation) + } + ) + } + + /// 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( + context, + onChange: apply, task: { transaction, operation in Task { await operation() @@ -85,23 +174,96 @@ import ConcurrencyExtras } #endif +/// 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( + @_inheritActorContext + _ apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void, + @_inheritActorContext + task: @escaping @isolated(any) @Sendable ( + _ transaction: UITransaction, + _ operation: @escaping @isolated(any) @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } +) -> ObserveToken { + return SwiftNavigation.onChange( + of: apply, + perform: apply, + task: task + ) +} + +/// 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( - _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void + @_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 @isolated(any) @Sendable () -> Void + ) -> Void = { + Task(operation: $1) + } +) -> ObserveToken { + let token = SwiftNavigation.onChange( + of: context, + perform: apply, + task: task + ) + + callWithUITransaction(.current, apply) + return token +} + +/// 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( + @_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 = { Task(operation: $1) } ) -> ObserveToken { let token = ObserveToken() - onChange( - { [weak token] transaction in + SwiftNavigation.withRecursivePerceptionTracking( + of: { [weak token] transaction in + guard let token, !token.isCancelled else { return } + callWithUITransaction(transaction, context) + }, + perform: { [weak token] transaction in guard let token, !token.isCancelled else { return } - var perform: @Sendable () -> Void = { apply(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 } @@ -121,21 +283,57 @@ func _observe( return token } -private func onChange( - _ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void, - task: @escaping @Sendable ( - _ transaction: UITransaction, _ operation: @escaping @isolated(any) @Sendable () -> Void +private func withRecursivePerceptionTracking( + @_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 { - apply(.current) + callWithUITransaction(.current, context) } onChange: { - task(.current) { - onChange(apply, task: task) + callWithUITransaction(.current, task) { + callWithUITransaction(.current, operation) + + withRecursivePerceptionTracking( + of: context, + perform: operation, + task: task + ) } } } +@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 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..43cf692ba --- /dev/null +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests+Nesting.swift @@ -0,0 +1,249 @@ +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 + + // 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( + MockTracker.shared.entries.withValue(\.count), + 13 + ) + } + + @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 + + // 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( + 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..82cfde3c8 --- /dev/null +++ b/Tests/SwiftNavigationTests/ObserveTests/ObserveTests.swift @@ -0,0 +1,139 @@ +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 { + value = b.value + innerCount += 1 + } + } + } + + 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, 3) // 2 redundant updates + XCTAssertEqual(innerCount, 3) // 1 redundant update + _ = 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.yeild() + // await Task.megaYeild() + // Falling back to Task.sleep + try? await Task.sleep(nanoseconds: UInt64(0.5 * pow(10, 9))) + + XCTAssertEqual(value, 1) + XCTAssertEqual(outerCount, 1) + 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