From 35d1bdb8bd29f13f8c7800f33a6c3a12dbe50ec8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 15 Jan 2024 11:36:43 -0800 Subject: [PATCH 01/15] Registrar-configurable perception checking --- Sources/Perception/PerceptionRegistrar.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index be238d2..ae86286 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -14,6 +14,7 @@ import Foundation @available(watchOS, deprecated: 10, renamed: "ObservationRegistrar") public struct PerceptionRegistrar: Sendable { private let _rawValue: AnySendable + private let isPerceptionCheckingEnabled: Bool /// Creates an instance of the observation registrar. /// @@ -21,7 +22,7 @@ public struct PerceptionRegistrar: Sendable { /// ``PerceptionRegistrar`` when using the /// ``Perception/Perceptible()`` macro to indicate observably /// of a type. - public init() { + public init(isPerceptionCheckingEnabled: Bool = Perception.isPerceptionCheckingEnabled) { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { #if canImport(Observation) self._rawValue = AnySendable(ObservationRegistrar()) @@ -31,6 +32,7 @@ public struct PerceptionRegistrar: Sendable { } else { self._rawValue = AnySendable(_PerceptionRegistrar()) } + self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled } #if canImport(Observation) @@ -80,8 +82,10 @@ extension PerceptionRegistrar { _ subject: Subject, keyPath: KeyPath ) { - perceptionCheck() - + if self.isPerceptionCheckingEnabled { + perceptionCheck() + } + #if canImport(Observation) if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { func `open`(_ subject: T) { From c841fa0431dcef52a7d3eb337f5c4638df84600c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 18 Jan 2024 18:59:40 -0800 Subject: [PATCH 02/15] perception performance --- Example/Example/ContentView.swift | 2 ++ Sources/Perception/PerceptionRegistrar.swift | 27 +++++++++++++++---- .../PerceptionMacros/PerceptibleMacro.swift | 6 +++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Example/Example/ContentView.swift b/Example/Example/ContentView.swift index 32d8559..cb8b97d 100644 --- a/Example/Example/ContentView.swift +++ b/Example/Example/ContentView.swift @@ -6,6 +6,7 @@ class CounterModel { var count = 0 var isDisplayingCount = true var isPresentingSheet = false + var text = "" func decrementButtonTapped() { count -= 1 } @@ -24,6 +25,7 @@ struct ContentView: View { WithPerceptionTracking { let _ = print("\(Self.self): tracked change.") Form { + TextField("Text", text: $model.text) if model.isDisplayingCount { Text(model.count.description) } else { diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index ae86286..23797fd 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -80,10 +80,12 @@ extension PerceptionRegistrar { @_disfavoredOverload public func access( _ subject: Subject, - keyPath: KeyPath + keyPath: KeyPath, + file: StaticString, + line: UInt ) { if self.isPerceptionCheckingEnabled { - perceptionCheck() + perceptionCheck(file: file, line: line) } #if canImport(Observation) @@ -198,12 +200,12 @@ extension PerceptionRegistrar: Hashable { } #if DEBUG - private func perceptionCheck() { + private func perceptionCheck(file: StaticString/* = #file*/, line: UInt/* = #line*/) { if isPerceptionCheckingEnabled, !_PerceptionLocals.isInPerceptionTracking, !_PerceptionLocals.skipPerceptionChecking, - isInSwiftUIBody() + isInSwiftUIBody(file, line) { runtimeWarn( """ @@ -214,11 +216,25 @@ extension PerceptionRegistrar: Hashable { } } - private let isInSwiftUIBody: () -> Bool = memoize { +private var resultsByFileLine: [FileLine: Bool] = [:] +struct FileLine: Hashable { + let file: String + let line: UInt + init(file: StaticString, line: UInt) { + self.file = file.description + self.line = line + } +} + + private let isInSwiftUIBody: (StaticString, UInt) -> Bool = { file, line in + if let result = resultsByFileLine[FileLine(file: file, line: line)] { + return result + } for callStackSymbol in Thread.callStackSymbols { let mangledSymbol = callStackSymbol.utf8 .drop(while: { $0 != .init(ascii: "$") }) .prefix(while: { $0 != .init(ascii: " ") }) + guard mangledSymbol.isMangledViewBodyGetter, let demangled = String(Substring(mangledSymbol)).demangled, @@ -228,6 +244,7 @@ extension PerceptionRegistrar: Hashable { } return true } + resultsByFileLine[FileLine(file: file, line: line)] = false return false } diff --git a/Sources/PerceptionMacros/PerceptibleMacro.swift b/Sources/PerceptionMacros/PerceptibleMacro.swift index 5953431..4f15c00 100644 --- a/Sources/PerceptionMacros/PerceptibleMacro.swift +++ b/Sources/PerceptionMacros/PerceptibleMacro.swift @@ -48,9 +48,11 @@ public struct PerceptibleMacro { return """ internal nonisolated func access( - keyPath: KeyPath<\(perceptibleType), Member> + keyPath: KeyPath<\(perceptibleType), Member>, + file: StaticString = #file, + line: UInt = #line ) { - \(raw: registrarVariableName).access(self, keyPath: keyPath) + \(raw: registrarVariableName).access(self, keyPath: keyPath, file: file, line: line) } """ } From 35396b563200c998626c132e08831ae5df485c91 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 08:08:34 -0800 Subject: [PATCH 03/15] wip --- Sources/Perception/PerceptionRegistrar.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index 23797fd..a7e9f96 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -81,8 +81,8 @@ extension PerceptionRegistrar { public func access( _ subject: Subject, keyPath: KeyPath, - file: StaticString, - line: UInt + file: StaticString = #file, + line: UInt = #line ) { if self.isPerceptionCheckingEnabled { perceptionCheck(file: file, line: line) @@ -200,7 +200,7 @@ extension PerceptionRegistrar: Hashable { } #if DEBUG - private func perceptionCheck(file: StaticString/* = #file*/, line: UInt/* = #line*/) { + private func perceptionCheck(file: StaticString, line: UInt) { if isPerceptionCheckingEnabled, !_PerceptionLocals.isInPerceptionTracking, @@ -216,6 +216,7 @@ extension PerceptionRegistrar: Hashable { } } +// TODO: lock private var resultsByFileLine: [FileLine: Bool] = [:] struct FileLine: Hashable { let file: String From 486004cfd17696bdf0615a5921b0937c969e9227 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 09:10:24 -0800 Subject: [PATCH 04/15] clean up and tests --- Sources/Perception/PerceptionRegistrar.swift | 176 ++++++++++++++---- .../PerceptionTests/RuntimeWarningTests.swift | 80 ++++++++ 2 files changed, 222 insertions(+), 34 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index a7e9f96..3e45176 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -4,6 +4,7 @@ import Foundation import Observation #endif + /// Provides storage for tracking and access to data changes. /// /// You don't need to create an instance of `PerceptionRegistrar` when using @@ -15,6 +16,7 @@ import Foundation public struct PerceptionRegistrar: Sendable { private let _rawValue: AnySendable private let isPerceptionCheckingEnabled: Bool + fileprivate let perceptionChecks = LockIsolated<[FileLine: Bool]>([:]) /// Creates an instance of the observation registrar. /// @@ -84,9 +86,7 @@ extension PerceptionRegistrar { file: StaticString = #file, line: UInt = #line ) { - if self.isPerceptionCheckingEnabled { - perceptionCheck(file: file, line: line) - } + self.perceptionCheck(file: file, line: line) #if canImport(Observation) if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { @@ -200,12 +200,14 @@ extension PerceptionRegistrar: Hashable { } #if DEBUG - private func perceptionCheck(file: StaticString, line: UInt) { +extension PerceptionRegistrar { + fileprivate func perceptionCheck(file: StaticString, line: UInt) { if - isPerceptionCheckingEnabled, + self.isPerceptionCheckingEnabled, + Perception.isPerceptionCheckingEnabled, !_PerceptionLocals.isInPerceptionTracking, !_PerceptionLocals.skipPerceptionChecking, - isInSwiftUIBody(file, line) + self.isInSwiftUIBody(file: file, line: line) { runtimeWarn( """ @@ -216,38 +218,30 @@ extension PerceptionRegistrar: Hashable { } } -// TODO: lock -private var resultsByFileLine: [FileLine: Bool] = [:] -struct FileLine: Hashable { - let file: String - let line: UInt - init(file: StaticString, line: UInt) { - self.file = file.description - self.line = line - } -} - - private let isInSwiftUIBody: (StaticString, UInt) -> Bool = { file, line in - if let result = resultsByFileLine[FileLine(file: file, line: line)] { - return result - } - for callStackSymbol in Thread.callStackSymbols { - let mangledSymbol = callStackSymbol.utf8 - .drop(while: { $0 != .init(ascii: "$") }) - .prefix(while: { $0 != .init(ascii: " ") }) + fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { + self.perceptionChecks.withValue { perceptionChecks in + if let result = perceptionChecks[FileLine(file: file, line: line)] { + return result + } + for callStackSymbol in Thread.callStackSymbols { + let mangledSymbol = callStackSymbol.utf8 + .drop(while: { $0 != .init(ascii: "$") }) + .prefix(while: { $0 != .init(ascii: " ") }) - guard - mangledSymbol.isMangledViewBodyGetter, - let demangled = String(Substring(mangledSymbol)).demangled, - !demangled.isActionClosure - else { - continue + guard + mangledSymbol.isMangledViewBodyGetter, + let demangled = String(Substring(mangledSymbol)).demangled, + !demangled.isActionClosure + else { + continue + } + return true } - return true + perceptionChecks[FileLine(file: file, line: line)] = false + return false } - resultsByFileLine[FileLine(file: file, line: line)] = false - return false } +} extension String { fileprivate var isActionClosure: Bool { @@ -339,3 +333,117 @@ extension Substring.UTF8View { return false } } + + +import Foundation + +/// A generic wrapper for isolating a mutable value with a lock. +/// +/// To asynchronously isolate a value on an actor, see ``ActorIsolated``. If you trust the +/// sendability of the underlying value, consider using ``UncheckedSendable``, instead. +@dynamicMemberLookup +public final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + + /// Initializes lock-isolated state around a value. + /// + /// - Parameter value: A value to isolate with a lock. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.lock.sync { + self._value[keyPath: keyPath] + } + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func increment() { + /// // Safely increment it: + /// self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// - Parameter operation: An operation to be performed on the the underlying value with a lock. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + try self.lock.sync { + var value = self._value + defer { self._value = value } + return try operation(&value) + } + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func reset() { + /// // Reset it: + /// self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of ``setValue(_:)`` if the value being set is derived + /// > from the current value. That is, do this: + /// > + /// > ```swift + /// > self.count.withValue { $0 += 1 } + /// > ``` + /// > + /// > ...and not this: + /// > + /// > ```swift + /// > self.count.setValue(self.count + 1) + /// > ``` + /// > + /// > ``withValue(_:)`` isolates the entire transaction and avoids data races between reading and + /// > writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + try self.lock.sync { + self._value = try newValue() + } + } +} + +extension LockIsolated where Value: Sendable { + /// The lock-isolated value. + public var value: Value { + self.lock.sync { + self._value + } + } +} + +extension NSRecursiveLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try work() + } +} + +private struct FileLine: Hashable { + let file: String + let line: UInt + init(file: StaticString, line: UInt) { + self.file = file.description + self.line = line + } +} diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index 5bf1f08..619de30 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -329,6 +329,86 @@ final class RuntimeWarningTests: XCTestCase { self.render(FeatureView()) } + func testRegistrarDisablePerceptionTracking() { + struct FeatureView: View { + let model = Model() + let registrar = PerceptionRegistrar(isPerceptionCheckingEnabled: false) + var body: some View { + let _ = registrar.access(model, keyPath: \.count) + Text("Hi") + } + } + self.render(FeatureView()) + } + + func testGlobalDisablePerceptionTracking() { + let previous = Perception.isPerceptionCheckingEnabled + Perception.isPerceptionCheckingEnabled = false + defer { Perception.isPerceptionCheckingEnabled = previous } + + struct FeatureView: View { + let model = Model() + var body: some View { + Text(model.count.description) + } + } + self.render(FeatureView()) + } + + func testParentAccessingChildState_ParentNotObserving_ChildObserving() { + self.expectFailure() + + struct ChildView: View { + let model: Model + var body: some View { + WithPerceptionTracking { + Text(model.count.description) + } + } + } + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + } + + self.render(FeatureView()) + } + + func testParentAccessingChildState_ParentObserving_ChildNotObserving() { + self.expectFailure() + + struct ChildView: View { + let model: Model + var body: some View { + Text(model.count.description) + } + } + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + WithPerceptionTracking { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + } + } + + self.render(FeatureView()) + } + private func expectFailure() { XCTExpectFailure { $0.compactDescription == """ From 2faaab15aa475ff8954a048bd2cef266873d6031 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 09:23:22 -0800 Subject: [PATCH 05/15] wip --- .../PerceptionTests/RuntimeWarningTests.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index 619de30..d724168 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -409,6 +409,58 @@ final class RuntimeWarningTests: XCTestCase { self.render(FeatureView()) } + func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() { + self.expectFailure() + + struct ChildView: View { + let model: Model + var body: some View { + Text(model.count.description) + } + } + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + } + + self.render(FeatureView()) + } + + func testParentAccessingChildState_ParentObserving_ChildObserving() { + struct ChildView: View { + let model: Model + var body: some View { + WithPerceptionTracking { + Text(model.count.description) + } + } + } + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + WithPerceptionTracking { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + } + } + + self.render(FeatureView()) + } + private func expectFailure() { XCTExpectFailure { $0.compactDescription == """ From 02a8f4a5cc2327d078bab7e6901c7e6c7f535095 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 10:10:36 -0800 Subject: [PATCH 06/15] wip --- Sources/Perception/PerceptionRegistrar.swift | 78 +++++++++----------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index 3e45176..02a04af 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -4,7 +4,6 @@ import Foundation import Observation #endif - /// Provides storage for tracking and access to data changes. /// /// You don't need to create an instance of `PerceptionRegistrar` when using @@ -200,48 +199,47 @@ extension PerceptionRegistrar: Hashable { } #if DEBUG -extension PerceptionRegistrar { - fileprivate func perceptionCheck(file: StaticString, line: UInt) { - if - self.isPerceptionCheckingEnabled, - Perception.isPerceptionCheckingEnabled, - !_PerceptionLocals.isInPerceptionTracking, - !_PerceptionLocals.skipPerceptionChecking, - self.isInSwiftUIBody(file: file, line: line) - { - runtimeWarn( - """ - Perceptible state was accessed but is not being tracked. Track changes to state by \ - wrapping your view in a 'WithPerceptionTracking' view. - """ - ) + extension PerceptionRegistrar { + fileprivate func perceptionCheck(file: StaticString, line: UInt) { + if self.isPerceptionCheckingEnabled, + Perception.isPerceptionCheckingEnabled, + !_PerceptionLocals.isInPerceptionTracking, + !_PerceptionLocals.skipPerceptionChecking, + self.isInSwiftUIBody(file: file, line: line) + { + runtimeWarn( + """ + Perceptible state was accessed but is not being tracked. Track changes to state by \ + wrapping your view in a 'WithPerceptionTracking' view. + """ + ) + } } - } - fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { - self.perceptionChecks.withValue { perceptionChecks in - if let result = perceptionChecks[FileLine(file: file, line: line)] { - return result - } - for callStackSymbol in Thread.callStackSymbols { - let mangledSymbol = callStackSymbol.utf8 - .drop(while: { $0 != .init(ascii: "$") }) - .prefix(while: { $0 != .init(ascii: " ") }) - - guard - mangledSymbol.isMangledViewBodyGetter, - let demangled = String(Substring(mangledSymbol)).demangled, - !demangled.isActionClosure - else { - continue + fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { + self.perceptionChecks.withValue { perceptionChecks in + if let result = perceptionChecks[FileLine(file: file, line: line)] { + return result } - return true + for callStackSymbol in Thread.callStackSymbols { + let mangledSymbol = callStackSymbol.utf8 + .drop(while: { $0 != .init(ascii: "$") }) + .prefix(while: { $0 != .init(ascii: " ") }) + + guard + mangledSymbol.isMangledViewBodyGetter, + let demangled = String(Substring(mangledSymbol)).demangled, + !demangled.isActionClosure + else { + continue + } + return true + } + perceptionChecks[FileLine(file: file, line: line)] = false + return false } - perceptionChecks[FileLine(file: file, line: line)] = false - return false } } -} extension String { fileprivate var isActionClosure: Bool { @@ -322,8 +320,7 @@ extension Substring.UTF8View { var input = self while let index = input.firstIndex(where: { first == $0 }) { input = input[index...] - if - input.count >= otherCount, + if input.count >= otherCount, zip(input, other).allSatisfy(==) { return true @@ -334,9 +331,6 @@ extension Substring.UTF8View { } } - -import Foundation - /// A generic wrapper for isolating a mutable value with a lock. /// /// To asynchronously isolate a value on an actor, see ``ActorIsolated``. If you trust the From 5b01ef6b7979b66d72e1360ff978857fa7a305fe Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 10:12:25 -0800 Subject: [PATCH 07/15] wip --- Sources/Perception/PerceptionRegistrar.swift | 83 +------------------- 1 file changed, 3 insertions(+), 80 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index 02a04af..02ff4e3 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -331,45 +331,13 @@ extension Substring.UTF8View { } } -/// A generic wrapper for isolating a mutable value with a lock. -/// -/// To asynchronously isolate a value on an actor, see ``ActorIsolated``. If you trust the -/// sendability of the underlying value, consider using ``UncheckedSendable``, instead. -@dynamicMemberLookup -public final class LockIsolated: @unchecked Sendable { +fileprivate final class LockIsolated: @unchecked Sendable { private var _value: Value private let lock = NSRecursiveLock() - - /// Initializes lock-isolated state around a value. - /// - /// - Parameter value: A value to isolate with a lock. - public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { self._value = try value() } - - public subscript(dynamicMember keyPath: KeyPath) -> Subject { - self.lock.sync { - self._value[keyPath: keyPath] - } - } - - /// Perform an operation with isolated access to the underlying value. - /// - /// Useful for modifying a value in a single transaction. - /// - /// ```swift - /// // Isolate an integer for concurrent read/write access: - /// var count = LockIsolated(0) - /// - /// func increment() { - /// // Safely increment it: - /// self.count.withValue { $0 += 1 } - /// } - /// ``` - /// - /// - Parameter operation: An operation to be performed on the the underlying value with a lock. - /// - Returns: The result of the operation. - public func withValue( + func withValue( _ operation: @Sendable (inout Value) throws -> T ) rethrows -> T { try self.lock.sync { @@ -378,52 +346,7 @@ public final class LockIsolated: @unchecked Sendable { return try operation(&value) } } - - /// Overwrite the isolated value with a new value. - /// - /// ```swift - /// // Isolate an integer for concurrent read/write access: - /// var count = LockIsolated(0) - /// - /// func reset() { - /// // Reset it: - /// self.count.setValue(0) - /// } - /// ``` - /// - /// > Tip: Use ``withValue(_:)`` instead of ``setValue(_:)`` if the value being set is derived - /// > from the current value. That is, do this: - /// > - /// > ```swift - /// > self.count.withValue { $0 += 1 } - /// > ``` - /// > - /// > ...and not this: - /// > - /// > ```swift - /// > self.count.setValue(self.count + 1) - /// > ``` - /// > - /// > ``withValue(_:)`` isolates the entire transaction and avoids data races between reading and - /// > writing the value. - /// - /// - Parameter newValue: The value to replace the current isolated value with. - public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { - try self.lock.sync { - self._value = try newValue() - } - } -} - -extension LockIsolated where Value: Sendable { - /// The lock-isolated value. - public var value: Value { - self.lock.sync { - self._value - } - } } - extension NSRecursiveLock { @inlinable @discardableResult @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { From c7bdc0a3a052fc4b3e054842e6b7fe93df776fb5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:04:40 -0800 Subject: [PATCH 08/15] wip --- .../PerceptionTests/RuntimeWarningTests.swift | 81 +++++++------------ 1 file changed, 31 insertions(+), 50 deletions(-) diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index d724168..f531c50 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -20,25 +20,21 @@ final class RuntimeWarningTests: XCTestCase { } func testNotInPerceptionBody_InSwiftUIBody() { - self.expectFailure() - struct FeatureView: View { let model = Model() var body: some View { - Text(self.model.count.description) + Text(expectRuntimeWarning { self.model.count }.description) } } self.render(FeatureView()) } func testNotInPerceptionBody_InSwiftUIBody_Wrapper() { - self.expectFailure() - struct FeatureView: View { let model = Model() var body: some View { Wrapper { - Text(self.model.count.description) + Text(expectRuntimeWarning { self.model.count }.description) } } } @@ -72,13 +68,11 @@ final class RuntimeWarningTests: XCTestCase { } func testNotInPerceptionBody_SwiftUIBinding() { - self.expectFailure() - struct FeatureView: View { @State var model = Model() var body: some View { - VStack { - TextField("", text: self.$model.text) + Form { + TextField("", text: expectRuntimeWarning { self.$model.text }) } } } @@ -98,8 +92,6 @@ final class RuntimeWarningTests: XCTestCase { } func testNotInPerceptionBody_ForEach() { - self.expectFailure() - struct FeatureView: View { @State var model = Model( list: [ @@ -109,8 +101,8 @@ final class RuntimeWarningTests: XCTestCase { ] ) var body: some View { - ForEach(model.list) { model in - Text(model.count.description) + ForEach(expectRuntimeWarning { model.list }) { model in + Text(expectRuntimeWarning { model.count }.description) } } } @@ -119,8 +111,6 @@ final class RuntimeWarningTests: XCTestCase { } func testInnerInPerceptionBody_ForEach() { - self.expectFailure() - struct FeatureView: View { @State var model = Model( list: [ @@ -130,7 +120,7 @@ final class RuntimeWarningTests: XCTestCase { ] ) var body: some View { - ForEach(model.list) { model in + ForEach(expectRuntimeWarning { model.list }) { model in WithPerceptionTracking { Text(model.count.description) } @@ -142,8 +132,6 @@ final class RuntimeWarningTests: XCTestCase { } func testOuterInPerceptionBody_ForEach() { - self.expectFailure() - struct FeatureView: View { @State var model = Model( list: [ @@ -155,7 +143,7 @@ final class RuntimeWarningTests: XCTestCase { var body: some View { WithPerceptionTracking { ForEach(model.list) { model in - Text(model.count.description) + Text(expectRuntimeWarning { model.count }.description) } } } @@ -188,29 +176,26 @@ final class RuntimeWarningTests: XCTestCase { } func testNotInPerceptionBody_Sheet() { - self.expectFailure() - struct FeatureView: View { @State var model = Model(child: Model()) var body: some View { Text("Parent") - .sheet(item: $model.child) { child in - Text(child.count.description) + .sheet(item: expectRuntimeWarning { $model.child }) { child in + Text(expectRuntimeWarning { child.count }.description) } } } + // TODO self.render(FeatureView()) } func testInnerInPerceptionBody_Sheet() { - self.expectFailure() - struct FeatureView: View { @State var model = Model(child: Model()) var body: some View { Text("Parent") - .sheet(item: $model.child) { child in + .sheet(item: expectRuntimeWarning { $model.child }) { child in WithPerceptionTracking { Text(child.count.description) } @@ -222,15 +207,13 @@ final class RuntimeWarningTests: XCTestCase { } func testOuterInPerceptionBody_Sheet() { - self.expectFailure() - struct FeatureView: View { @State var model = Model(child: Model()) var body: some View { WithPerceptionTracking { Text("Parent") .sheet(item: $model.child) { child in - Text(child.count.description) + Text(expectRuntimeWarning { child.count }.description) } } } @@ -356,13 +339,12 @@ final class RuntimeWarningTests: XCTestCase { } func testParentAccessingChildState_ParentNotObserving_ChildObserving() { - self.expectFailure() - struct ChildView: View { let model: Model var body: some View { WithPerceptionTracking { Text(model.count.description) + .onAppear { let _ = model.count } } } } @@ -374,8 +356,11 @@ final class RuntimeWarningTests: XCTestCase { self.model = Model(list: [self.childModel]) } var body: some View { - ChildView(model: self.childModel) - Text(childModel.count.description) + VStack { + ChildView(model: self.childModel) + Text(expectRuntimeWarning { childModel.count }.description) + } + .onAppear { let _ = childModel.count } } } @@ -383,12 +368,10 @@ final class RuntimeWarningTests: XCTestCase { } func testParentAccessingChildState_ParentObserving_ChildNotObserving() { - self.expectFailure() - struct ChildView: View { let model: Model var body: some View { - Text(model.count.description) + Text(expectRuntimeWarning { model.count }.description) } } struct FeatureView: View { @@ -410,12 +393,10 @@ final class RuntimeWarningTests: XCTestCase { } func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() { - self.expectFailure() - struct ChildView: View { let model: Model var body: some View { - Text(model.count.description) + Text(expectRuntimeWarning { model.count }.description) } } struct FeatureView: View { @@ -427,7 +408,7 @@ final class RuntimeWarningTests: XCTestCase { } var body: some View { ChildView(model: self.childModel) - Text(childModel.count.description) + Text(expectRuntimeWarning { childModel.count }.description) } } @@ -461,21 +442,21 @@ final class RuntimeWarningTests: XCTestCase { self.render(FeatureView()) } - private func expectFailure() { - XCTExpectFailure { - $0.compactDescription == """ - Perceptible state was accessed but is not being tracked. Track changes to state by \ - wrapping your view in a 'WithPerceptionTracking' view. - """ - } - } - private func render(_ view: some View) { let image = ImageRenderer(content: view).cgImage _ = image } } +private func expectRuntimeWarning(failingBlock: () -> R) -> R { + XCTExpectFailure(failingBlock: failingBlock) { + $0.compactDescription == """ + Perceptible state was accessed but is not being tracked. Track changes to state by \ + wrapping your view in a 'WithPerceptionTracking' view. + """ + } +} + @Perceptible private class Model: Identifiable { var child: Model? From e725fbe6da34c3398cf1aee0f640c6df9fb1c315 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:05:37 -0800 Subject: [PATCH 09/15] wip --- Tests/PerceptionTests/RuntimeWarningTests.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index f531c50..afd2bb9 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -372,6 +372,7 @@ final class RuntimeWarningTests: XCTestCase { let model: Model var body: some View { Text(expectRuntimeWarning { model.count }.description) + .onAppear { let _ = model.count } } } struct FeatureView: View { @@ -386,6 +387,7 @@ final class RuntimeWarningTests: XCTestCase { ChildView(model: self.childModel) Text(childModel.count.description) } + .onAppear { let _ = childModel.count } } } @@ -397,6 +399,7 @@ final class RuntimeWarningTests: XCTestCase { let model: Model var body: some View { Text(expectRuntimeWarning { model.count }.description) + .onAppear { let _ = model.count } } } struct FeatureView: View { @@ -407,8 +410,11 @@ final class RuntimeWarningTests: XCTestCase { self.model = Model(list: [self.childModel]) } var body: some View { - ChildView(model: self.childModel) - Text(expectRuntimeWarning { childModel.count }.description) + VStack { + ChildView(model: self.childModel) + Text(expectRuntimeWarning { childModel.count }.description) + } + .onAppear { let _ = childModel.count } } } @@ -421,6 +427,7 @@ final class RuntimeWarningTests: XCTestCase { var body: some View { WithPerceptionTracking { Text(model.count.description) + .onAppear { let _ = model.count } } } } @@ -436,6 +443,7 @@ final class RuntimeWarningTests: XCTestCase { ChildView(model: self.childModel) Text(childModel.count.description) } + .onAppear { let _ = childModel.count } } } From 8fa1d939f58697dd7c527fa5ea2b9145d9d06ea4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:06:19 -0800 Subject: [PATCH 10/15] wip --- Sources/Perception/Internal/Memoization.swift | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 Sources/Perception/Internal/Memoization.swift diff --git a/Sources/Perception/Internal/Memoization.swift b/Sources/Perception/Internal/Memoization.swift deleted file mode 100644 index 5a2d728..0000000 --- a/Sources/Perception/Internal/Memoization.swift +++ /dev/null @@ -1,45 +0,0 @@ -#if DEBUG - import Foundation - import OrderedCollections - - func memoize( - maxCapacity: Int = 500, - _ apply: @escaping () -> Result - ) -> () -> Result { - let cache = Cache<[NSNumber], Result>(maxCapacity: maxCapacity) - return { - let callStack = Thread.callStackReturnAddresses - guard let memoizedResult = cache[callStack] - else { - let result = apply() - defer { cache[callStack] = result } - return result - } - return memoizedResult - } - } - - private final class Cache: @unchecked Sendable { - var dictionary = OrderedDictionary() - var lock = NSLock() - let maxCapacity: Int - init(maxCapacity: Int = 500) { - self.maxCapacity = maxCapacity - } - subscript(key: Key) -> Value? { - get { - self.lock.sync { - self.dictionary[key] - } - } - set { - self.lock.sync { - self.dictionary[key] = newValue - if self.dictionary.count > self.maxCapacity { - self.dictionary.removeFirst() - } - } - } - } - } -#endif From fa69c748d0c6602cccd3ec14c00e26313446afc2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:07:11 -0800 Subject: [PATCH 11/15] wip --- Tests/PerceptionTests/RuntimeWarningTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index afd2bb9..24da458 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -186,7 +186,6 @@ final class RuntimeWarningTests: XCTestCase { } } - // TODO self.render(FeatureView()) } From e924180ce45aa04655d226bf7a12a298efa2e918 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:20:13 -0800 Subject: [PATCH 12/15] fix test --- Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift b/Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift index bfc43fc..afb8b59 100644 --- a/Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift +++ b/Tests/PerceptionMacrosTests/PerceptionMacrosTests.swift @@ -47,9 +47,11 @@ private let _$perceptionRegistrar = Perception.PerceptionRegistrar() internal nonisolated func access( - keyPath: KeyPath + keyPath: KeyPath, + file: StaticString = #file, + line: UInt = #line ) { - _$perceptionRegistrar.access(self, keyPath: keyPath) + _$perceptionRegistrar.access(self, keyPath: keyPath, file: file, line: line) } internal nonisolated func withMutation( From 7a4e026539b186a37365c2538c3def04672af9d4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:41:58 -0800 Subject: [PATCH 13/15] fix release builds. --- .github/workflows/ci.yml | 4 ++-- Sources/Perception/PerceptionRegistrar.swift | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c14dbb..f791862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,11 +19,11 @@ jobs: strategy: matrix: xcode: ['15.1'] - config: ['debug'] + config: ['debug', 'release'] runs-on: macos-13 steps: - uses: actions/checkout@v3 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run ${{ matrix.config }} tests - run: swift test + run: swift test -c ${{ matrix.config }} diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index 02ff4e3..71b4f2a 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -278,9 +278,11 @@ extension PerceptionRegistrar: Hashable { flags: UInt32 ) -> UnsafeMutablePointer? #else - @_transparent - @inline(__always) - private func perceptionCheck() {} + extension PerceptionRegistrar { + @_transparent + @inline(__always) + private func perceptionCheck(file: StaticString, line: UInt) {} + } #endif #if DEBUG @@ -331,7 +333,7 @@ extension Substring.UTF8View { } } -fileprivate final class LockIsolated: @unchecked Sendable { +private final class LockIsolated: @unchecked Sendable { private var _value: Value private let lock = NSRecursiveLock() init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { From f2af2baaa81e988531c8d7379736fbe65253fc1f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 19 Jan 2024 11:43:26 -0800 Subject: [PATCH 14/15] wrap runtime warnings tests in debug --- .../PerceptionTests/RuntimeWarningTests.swift | 722 +++++++++--------- 1 file changed, 362 insertions(+), 360 deletions(-) diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index 24da458..66aba0d 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -1,492 +1,494 @@ -import Combine -import Perception -import SwiftUI -import XCTest - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -@MainActor -final class RuntimeWarningTests: XCTestCase { - func testNotInPerceptionBody() { - let model = Model() - model.count += 1 - XCTAssertEqual(model.count, 1) - } - - func testInPerceptionBody_NotInSwiftUIBody() { - let model = Model() - _PerceptionLocals.$isInPerceptionTracking.withValue(true) { - _ = model.count +#if DEBUG + import Combine + import Perception + import SwiftUI + import XCTest + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + final class RuntimeWarningTests: XCTestCase { + func testNotInPerceptionBody() { + let model = Model() + model.count += 1 + XCTAssertEqual(model.count, 1) } - } - func testNotInPerceptionBody_InSwiftUIBody() { - struct FeatureView: View { + func testInPerceptionBody_NotInSwiftUIBody() { let model = Model() - var body: some View { - Text(expectRuntimeWarning { self.model.count }.description) + _PerceptionLocals.$isInPerceptionTracking.withValue(true) { + _ = model.count } } - self.render(FeatureView()) - } - func testNotInPerceptionBody_InSwiftUIBody_Wrapper() { - struct FeatureView: View { - let model = Model() - var body: some View { - Wrapper { + func testNotInPerceptionBody_InSwiftUIBody() { + struct FeatureView: View { + let model = Model() + var body: some View { Text(expectRuntimeWarning { self.model.count }.description) } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - func testInPerceptionBody_InSwiftUIBody_Wrapper() { - struct FeatureView: View { - let model = Model() - var body: some View { - WithPerceptionTracking { + func testNotInPerceptionBody_InSwiftUIBody_Wrapper() { + struct FeatureView: View { + let model = Model() + var body: some View { Wrapper { - Text(self.model.count.description) + Text(expectRuntimeWarning { self.model.count }.description) } } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - func testInPerceptionBody_InSwiftUIBody() { - struct FeatureView: View { - let model = Model() - var body: some View { - WithPerceptionTracking { - Text(self.model.count.description) - } - } - } - self.render(FeatureView()) - } - - func testNotInPerceptionBody_SwiftUIBinding() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Form { - TextField("", text: expectRuntimeWarning { self.$model.text }) + func testInPerceptionBody_InSwiftUIBody_Wrapper() { + struct FeatureView: View { + let model = Model() + var body: some View { + WithPerceptionTracking { + Wrapper { + Text(self.model.count.description) + } + } } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - func testInPerceptionBody_SwiftUIBinding() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - WithPerceptionTracking { - TextField("", text: self.$model.text) + func testInPerceptionBody_InSwiftUIBody() { + struct FeatureView: View { + let model = Model() + var body: some View { + WithPerceptionTracking { + Text(self.model.count.description) + } } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - func testNotInPerceptionBody_ForEach() { - struct FeatureView: View { - @State var model = Model( - list: [ - Model(count: 1), - Model(count: 2), - Model(count: 3), - ] - ) - var body: some View { - ForEach(expectRuntimeWarning { model.list }) { model in - Text(expectRuntimeWarning { model.count }.description) + func testNotInPerceptionBody_SwiftUIBinding() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Form { + TextField("", text: expectRuntimeWarning { self.$model.text }) + } } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - - func testInnerInPerceptionBody_ForEach() { - struct FeatureView: View { - @State var model = Model( - list: [ - Model(count: 1), - Model(count: 2), - Model(count: 3), - ] - ) - var body: some View { - ForEach(expectRuntimeWarning { model.list }) { model in + func testInPerceptionBody_SwiftUIBinding() { + struct FeatureView: View { + @State var model = Model() + var body: some View { WithPerceptionTracking { - Text(model.count.description) + TextField("", text: self.$model.text) } } } + self.render(FeatureView()) } - self.render(FeatureView()) - } - - func testOuterInPerceptionBody_ForEach() { - struct FeatureView: View { - @State var model = Model( - list: [ - Model(count: 1), - Model(count: 2), - Model(count: 3), - ] - ) - var body: some View { - WithPerceptionTracking { - ForEach(model.list) { model in + func testNotInPerceptionBody_ForEach() { + struct FeatureView: View { + @State var model = Model( + list: [ + Model(count: 1), + Model(count: 2), + Model(count: 3), + ] + ) + var body: some View { + ForEach(expectRuntimeWarning { model.list }) { model in Text(expectRuntimeWarning { model.count }.description) } } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testOuterAndInnerInPerceptionBody_ForEach() { - struct FeatureView: View { - @State var model = Model( - list: [ - Model(count: 1), - Model(count: 2), - Model(count: 3), - ] - ) - var body: some View { - WithPerceptionTracking { - ForEach(model.list) { model in + func testInnerInPerceptionBody_ForEach() { + struct FeatureView: View { + @State var model = Model( + list: [ + Model(count: 1), + Model(count: 2), + Model(count: 3), + ] + ) + var body: some View { + ForEach(expectRuntimeWarning { model.list }) { model in WithPerceptionTracking { Text(model.count.description) } } } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testNotInPerceptionBody_Sheet() { - struct FeatureView: View { - @State var model = Model(child: Model()) - var body: some View { - Text("Parent") - .sheet(item: expectRuntimeWarning { $model.child }) { child in - Text(expectRuntimeWarning { child.count }.description) + func testOuterInPerceptionBody_ForEach() { + struct FeatureView: View { + @State var model = Model( + list: [ + Model(count: 1), + Model(count: 2), + Model(count: 3), + ] + ) + var body: some View { + WithPerceptionTracking { + ForEach(model.list) { model in + Text(expectRuntimeWarning { model.count }.description) + } } + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testInnerInPerceptionBody_Sheet() { - struct FeatureView: View { - @State var model = Model(child: Model()) - var body: some View { - Text("Parent") - .sheet(item: expectRuntimeWarning { $model.child }) { child in - WithPerceptionTracking { - Text(child.count.description) + func testOuterAndInnerInPerceptionBody_ForEach() { + struct FeatureView: View { + @State var model = Model( + list: [ + Model(count: 1), + Model(count: 2), + Model(count: 3), + ] + ) + var body: some View { + WithPerceptionTracking { + ForEach(model.list) { model in + WithPerceptionTracking { + Text(model.count.description) + } } } + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testOuterInPerceptionBody_Sheet() { - struct FeatureView: View { - @State var model = Model(child: Model()) - var body: some View { - WithPerceptionTracking { + func testNotInPerceptionBody_Sheet() { + struct FeatureView: View { + @State var model = Model(child: Model()) + var body: some View { Text("Parent") - .sheet(item: $model.child) { child in + .sheet(item: expectRuntimeWarning { $model.child }) { child in Text(expectRuntimeWarning { child.count }.description) } } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testOuterAndInnerInPerceptionBody_Sheet() { - struct FeatureView: View { - @State var model = Model(child: Model()) - var body: some View { - WithPerceptionTracking { + func testInnerInPerceptionBody_Sheet() { + struct FeatureView: View { + @State var model = Model(child: Model()) + var body: some View { Text("Parent") - .sheet(item: $model.child) { child in + .sheet(item: expectRuntimeWarning { $model.child }) { child in WithPerceptionTracking { Text(child.count.description) } } } } - } - - self.render(FeatureView()) - } - func testActionClosure() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Text("Hi") - .onAppear { _ = self.model.count } - } + self.render(FeatureView()) } - self.render(FeatureView()) - } - - func testActionClosure_CallMethodWithArguments() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Text("Hi") - .onAppear { _ = foo(42) } - } - func foo(_: Int) -> Bool { - _ = self.model.count - return true + func testOuterInPerceptionBody_Sheet() { + struct FeatureView: View { + @State var model = Model(child: Model()) + var body: some View { + WithPerceptionTracking { + Text("Parent") + .sheet(item: $model.child) { child in + Text(expectRuntimeWarning { child.count }.description) + } + } + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testActionClosure_WithArguments() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Text("Hi") - .onReceive(Just(1)) { _ in - _ = self.model.count + func testOuterAndInnerInPerceptionBody_Sheet() { + struct FeatureView: View { + @State var model = Model(child: Model()) + var body: some View { + WithPerceptionTracking { + Text("Parent") + .sheet(item: $model.child) { child in + WithPerceptionTracking { + Text(child.count.description) + } + } } + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testActionClosure_WithArguments_ImplicitClosure() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Text("Hi") - .onReceive(Just(1), perform: self.foo) - } - func foo(_: Int) { - _ = self.model.count + func testActionClosure() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Text("Hi") + .onAppear { _ = self.model.count } + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testImplicitActionClosure() { - struct FeatureView: View { - @State var model = Model() - var body: some View { - Text("Hi") - .onAppear(perform: foo) - } - func foo() { - _ = self.model.count + func testActionClosure_CallMethodWithArguments() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Text("Hi") + .onAppear { _ = foo(42) } + } + func foo(_: Int) -> Bool { + _ = self.model.count + return true + } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testRegistrarDisablePerceptionTracking() { - struct FeatureView: View { - let model = Model() - let registrar = PerceptionRegistrar(isPerceptionCheckingEnabled: false) - var body: some View { - let _ = registrar.access(model, keyPath: \.count) - Text("Hi") + func testActionClosure_WithArguments() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Text("Hi") + .onReceive(Just(1)) { _ in + _ = self.model.count + } + } } - } - self.render(FeatureView()) - } - func testGlobalDisablePerceptionTracking() { - let previous = Perception.isPerceptionCheckingEnabled - Perception.isPerceptionCheckingEnabled = false - defer { Perception.isPerceptionCheckingEnabled = previous } + self.render(FeatureView()) + } - struct FeatureView: View { - let model = Model() - var body: some View { - Text(model.count.description) + func testActionClosure_WithArguments_ImplicitClosure() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Text("Hi") + .onReceive(Just(1), perform: self.foo) + } + func foo(_: Int) { + _ = self.model.count + } } + + self.render(FeatureView()) } - self.render(FeatureView()) - } - func testParentAccessingChildState_ParentNotObserving_ChildObserving() { - struct ChildView: View { - let model: Model - var body: some View { - WithPerceptionTracking { - Text(model.count.description) - .onAppear { let _ = model.count } + func testImplicitActionClosure() { + struct FeatureView: View { + @State var model = Model() + var body: some View { + Text("Hi") + .onAppear(perform: foo) + } + func foo() { + _ = self.model.count } } + + self.render(FeatureView()) } - struct FeatureView: View { - let model: Model - let childModel: Model - init() { - self.childModel = Model() - self.model = Model(list: [self.childModel]) - } - var body: some View { - VStack { - ChildView(model: self.childModel) - Text(expectRuntimeWarning { childModel.count }.description) + + func testRegistrarDisablePerceptionTracking() { + struct FeatureView: View { + let model = Model() + let registrar = PerceptionRegistrar(isPerceptionCheckingEnabled: false) + var body: some View { + let _ = registrar.access(model, keyPath: \.count) + Text("Hi") } - .onAppear { let _ = childModel.count } } + self.render(FeatureView()) } - self.render(FeatureView()) - } + func testGlobalDisablePerceptionTracking() { + let previous = Perception.isPerceptionCheckingEnabled + Perception.isPerceptionCheckingEnabled = false + defer { Perception.isPerceptionCheckingEnabled = previous } - func testParentAccessingChildState_ParentObserving_ChildNotObserving() { - struct ChildView: View { - let model: Model - var body: some View { - Text(expectRuntimeWarning { model.count }.description) - .onAppear { let _ = model.count } + struct FeatureView: View { + let model = Model() + var body: some View { + Text(model.count.description) + } } + self.render(FeatureView()) } - struct FeatureView: View { - let model: Model - let childModel: Model - init() { - self.childModel = Model() - self.model = Model(list: [self.childModel]) + + func testParentAccessingChildState_ParentNotObserving_ChildObserving() { + struct ChildView: View { + let model: Model + var body: some View { + WithPerceptionTracking { + Text(model.count.description) + .onAppear { let _ = model.count } + } + } } - var body: some View { - WithPerceptionTracking { - ChildView(model: self.childModel) - Text(childModel.count.description) + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + VStack { + ChildView(model: self.childModel) + Text(expectRuntimeWarning { childModel.count }.description) + } + .onAppear { let _ = childModel.count } } - .onAppear { let _ = childModel.count } } - } - - self.render(FeatureView()) - } - func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() { - struct ChildView: View { - let model: Model - var body: some View { - Text(expectRuntimeWarning { model.count }.description) - .onAppear { let _ = model.count } - } + self.render(FeatureView()) } - struct FeatureView: View { - let model: Model - let childModel: Model - init() { - self.childModel = Model() - self.model = Model(list: [self.childModel]) + + func testParentAccessingChildState_ParentObserving_ChildNotObserving() { + struct ChildView: View { + let model: Model + var body: some View { + Text(expectRuntimeWarning { model.count }.description) + .onAppear { let _ = model.count } + } } - var body: some View { - VStack { - ChildView(model: self.childModel) - Text(expectRuntimeWarning { childModel.count }.description) + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + WithPerceptionTracking { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + .onAppear { let _ = childModel.count } } - .onAppear { let _ = childModel.count } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - func testParentAccessingChildState_ParentObserving_ChildObserving() { - struct ChildView: View { - let model: Model - var body: some View { - WithPerceptionTracking { - Text(model.count.description) + func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() { + struct ChildView: View { + let model: Model + var body: some View { + Text(expectRuntimeWarning { model.count }.description) .onAppear { let _ = model.count } } } + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + VStack { + ChildView(model: self.childModel) + Text(expectRuntimeWarning { childModel.count }.description) + } + .onAppear { let _ = childModel.count } + } + } + + self.render(FeatureView()) } - struct FeatureView: View { - let model: Model - let childModel: Model - init() { - self.childModel = Model() - self.model = Model(list: [self.childModel]) + + func testParentAccessingChildState_ParentObserving_ChildObserving() { + struct ChildView: View { + let model: Model + var body: some View { + WithPerceptionTracking { + Text(model.count.description) + .onAppear { let _ = model.count } + } + } } - var body: some View { - WithPerceptionTracking { - ChildView(model: self.childModel) - Text(childModel.count.description) + struct FeatureView: View { + let model: Model + let childModel: Model + init() { + self.childModel = Model() + self.model = Model(list: [self.childModel]) + } + var body: some View { + WithPerceptionTracking { + ChildView(model: self.childModel) + Text(childModel.count.description) + } + .onAppear { let _ = childModel.count } } - .onAppear { let _ = childModel.count } } - } - self.render(FeatureView()) - } + self.render(FeatureView()) + } - private func render(_ view: some View) { - let image = ImageRenderer(content: view).cgImage - _ = image + private func render(_ view: some View) { + let image = ImageRenderer(content: view).cgImage + _ = image + } } -} -private func expectRuntimeWarning(failingBlock: () -> R) -> R { - XCTExpectFailure(failingBlock: failingBlock) { - $0.compactDescription == """ + private func expectRuntimeWarning(failingBlock: () -> R) -> R { + XCTExpectFailure(failingBlock: failingBlock) { + $0.compactDescription == """ Perceptible state was accessed but is not being tracked. Track changes to state by \ wrapping your view in a 'WithPerceptionTracking' view. """ + } } -} - -@Perceptible -private class Model: Identifiable { - var child: Model? - var count: Int - var list: [Model] - var text: String - - init( - child: Model? = nil, - count: Int = 0, - list: [Model] = [], - text: String = "" - ) { - self.child = child - self.count = count - self.list = list - self.text = text + + @Perceptible + private class Model: Identifiable { + var child: Model? + var count: Int + var list: [Model] + var text: String + + init( + child: Model? = nil, + count: Int = 0, + list: [Model] = [], + text: String = "" + ) { + self.child = child + self.count = count + self.list = list + self.text = text + } } -} -struct Wrapper: View { - @ViewBuilder var content: Content - var body: some View { - self.content + struct Wrapper: View { + @ViewBuilder var content: Content + var body: some View { + self.content + } } -} +#endif From b1be2567eea89f4124fd4b78d97a72f60e146d1d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 19 Jan 2024 12:09:28 -0800 Subject: [PATCH 15/15] cleanup --- Sources/Perception/PerceptionRegistrar.swift | 113 +++++++++---------- 1 file changed, 53 insertions(+), 60 deletions(-) diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift index 71b4f2a..0a35848 100644 --- a/Sources/Perception/PerceptionRegistrar.swift +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -14,8 +14,10 @@ import Foundation @available(watchOS, deprecated: 10, renamed: "ObservationRegistrar") public struct PerceptionRegistrar: Sendable { private let _rawValue: AnySendable - private let isPerceptionCheckingEnabled: Bool - fileprivate let perceptionChecks = LockIsolated<[FileLine: Bool]>([:]) + #if DEBUG + private let isPerceptionCheckingEnabled: Bool + fileprivate let perceptionChecks = LockIsolated<[Location: Bool]>([:]) + #endif /// Creates an instance of the observation registrar. /// @@ -33,7 +35,9 @@ public struct PerceptionRegistrar: Sendable { } else { self._rawValue = AnySendable(_PerceptionRegistrar()) } - self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled + #if DEBUG + self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled + #endif } #if canImport(Observation) @@ -85,8 +89,9 @@ extension PerceptionRegistrar { file: StaticString = #file, line: UInt = #line ) { - self.perceptionCheck(file: file, line: line) - + #if DEBUG + self.perceptionCheck(file: file, line: line) + #endif #if canImport(Observation) if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { func `open`(_ subject: T) { @@ -218,7 +223,7 @@ extension PerceptionRegistrar: Hashable { fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { self.perceptionChecks.withValue { perceptionChecks in - if let result = perceptionChecks[FileLine(file: file, line: line)] { + if let result = perceptionChecks[Location(file: file, line: line)] { return result } for callStackSymbol in Thread.callStackSymbols { @@ -235,7 +240,7 @@ extension PerceptionRegistrar: Hashable { } return true } - perceptionChecks[FileLine(file: file, line: line)] = false + perceptionChecks[Location(file: file, line: line)] = false return false } } @@ -277,12 +282,6 @@ extension PerceptionRegistrar: Hashable { outputBufferSize: UnsafeMutablePointer?, flags: UInt32 ) -> UnsafeMutablePointer? -#else - extension PerceptionRegistrar { - @_transparent - @inline(__always) - private func perceptionCheck(file: StaticString, line: UInt) {} - } #endif #if DEBUG @@ -311,58 +310,52 @@ extension PerceptionRegistrar: Hashable { } #endif -extension Substring.UTF8View { - fileprivate var isMangledViewBodyGetter: Bool { - self._contains("V4bodyQrvg".utf8) - } - fileprivate func _contains(_ other: String.UTF8View) -> Bool { - guard let first = other.first - else { return false } - let otherCount = other.count - var input = self - while let index = input.firstIndex(where: { first == $0 }) { - input = input[index...] - if input.count >= otherCount, - zip(input, other).allSatisfy(==) - { - return true +#if DEBUG + extension Substring.UTF8View { + fileprivate var isMangledViewBodyGetter: Bool { + self._contains("V4bodyQrvg".utf8) + } + fileprivate func _contains(_ other: String.UTF8View) -> Bool { + guard let first = other.first + else { return false } + let otherCount = other.count + var input = self + while let index = input.firstIndex(where: { first == $0 }) { + input = input[index...] + if input.count >= otherCount, + zip(input, other).allSatisfy(==) + { + return true + } + input.removeFirst() } - input.removeFirst() + return false } - return false } -} -private final class LockIsolated: @unchecked Sendable { - private var _value: Value - private let lock = NSRecursiveLock() - init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { - self._value = try value() - } - func withValue( - _ operation: @Sendable (inout Value) throws -> T - ) rethrows -> T { - try self.lock.sync { - var value = self._value - defer { self._value = value } - return try operation(&value) + private final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + try self.lock.withLock { + var value = self._value + defer { self._value = value } + return try operation(&value) + } } } -} -extension NSRecursiveLock { - @inlinable @discardableResult - @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { - self.lock() - defer { self.unlock() } - return try work() - } -} -private struct FileLine: Hashable { - let file: String - let line: UInt - init(file: StaticString, line: UInt) { - self.file = file.description - self.line = line + private struct Location: Hashable { + let file: String + let line: UInt + init(file: StaticString, line: UInt) { + self.file = file.description + self.line = line + } } -} +#endif