From 520c458a832d1287e6b698c5f657ae848fd696ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Apr 2024 15:57:54 -0700 Subject: [PATCH] Preserve `@Bindable`'s binding identity (#56) SwiftUI's vanilla `@Bindable` seems to preserve the underlying identity of its derived bindings, so we should do the same by holding onto a private binding over time under the hood. Fixes #55. --- Sources/Perception/Bindable.swift | 22 ++++++++---- .../Internal/UncheckedSendable.swift | 6 ++++ .../Perception/WithPerceptionTracking.swift | 7 ---- .../PerceptionTests/RuntimeWarningTests.swift | 35 ++++++++++++++++++- 4 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 Sources/Perception/Internal/UncheckedSendable.swift diff --git a/Sources/Perception/Bindable.swift b/Sources/Perception/Bindable.swift index 40f76ed..de137a3 100644 --- a/Sources/Perception/Bindable.swift +++ b/Sources/Perception/Bindable.swift @@ -13,8 +13,13 @@ @dynamicMemberLookup @propertyWrapper public struct Bindable { + private let binding: UncheckedSendable> + /// The wrapped object. - public var wrappedValue: Value + public var wrappedValue: Value { + get { self.binding.value.wrappedValue } + set { self.binding.value.wrappedValue = newValue } + } /// The bindable wrapper for the object that creates bindings to its properties using dynamic /// member lookup. @@ -26,20 +31,23 @@ public subscript( dynamicMember keyPath: ReferenceWritableKeyPath ) -> Binding where Value: AnyObject { - Binding( - get: { self.wrappedValue[keyPath: keyPath] }, - set: { self.wrappedValue[keyPath: keyPath] = $0 } - ) + self.binding.value[dynamicMember: keyPath] } /// Creates a bindable object from an observable object. public init(wrappedValue: Value) where Value: AnyObject & Perceptible { - self.wrappedValue = wrappedValue + var value = wrappedValue + self.binding = UncheckedSendable( + Binding( + get: { value }, + set: { value = $0 } + ) + ) } /// Creates a bindable object from an observable object. public init(_ wrappedValue: Value) where Value: AnyObject & Perceptible { - self.wrappedValue = wrappedValue + self.init(wrappedValue: wrappedValue) } /// Creates a bindable from the value of another bindable. diff --git a/Sources/Perception/Internal/UncheckedSendable.swift b/Sources/Perception/Internal/UncheckedSendable.swift new file mode 100644 index 0000000..1a33371 --- /dev/null +++ b/Sources/Perception/Internal/UncheckedSendable.swift @@ -0,0 +1,6 @@ +struct UncheckedSendable: @unchecked Sendable { + let value: Value + init(_ value: Value) { + self.value = value + } +} diff --git a/Sources/Perception/WithPerceptionTracking.swift b/Sources/Perception/WithPerceptionTracking.swift index e64a338..fe1e14b 100644 --- a/Sources/Perception/WithPerceptionTracking.swift +++ b/Sources/Perception/WithPerceptionTracking.swift @@ -164,10 +164,3 @@ public enum _PerceptionLocals { @TaskLocal public static var isInPerceptionTracking = false @TaskLocal public static var skipPerceptionChecking = false } - -private struct UncheckedSendable: @unchecked Sendable { - let value: A - init(_ value: A) { - self.value = value - } -} diff --git a/Tests/PerceptionTests/RuntimeWarningTests.swift b/Tests/PerceptionTests/RuntimeWarningTests.swift index 9690ea1..ea2e820 100644 --- a/Tests/PerceptionTests/RuntimeWarningTests.swift +++ b/Tests/PerceptionTests/RuntimeWarningTests.swift @@ -5,14 +5,15 @@ import XCTest @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - @MainActor final class RuntimeWarningTests: XCTestCase { + @MainActor func testNotInPerceptionBody() { let model = Model() model.count += 1 XCTAssertEqual(model.count, 1) } + @MainActor func testInPerceptionBody_NotInSwiftUIBody() { let model = Model() _PerceptionLocals.$isInPerceptionTracking.withValue(true) { @@ -20,6 +21,7 @@ } } + @MainActor func testNotInPerceptionBody_InSwiftUIBody() { struct FeatureView: View { let model = Model() @@ -30,6 +32,7 @@ self.render(FeatureView()) } + @MainActor func testNotInPerceptionBody_InSwiftUIBody_Wrapper() { struct FeatureView: View { let model = Model() @@ -42,6 +45,7 @@ self.render(FeatureView()) } + @MainActor func testInPerceptionBody_InSwiftUIBody_Wrapper() { struct FeatureView: View { let model = Model() @@ -56,6 +60,7 @@ self.render(FeatureView()) } + @MainActor func testInPerceptionBody_InSwiftUIBody() { struct FeatureView: View { let model = Model() @@ -68,6 +73,7 @@ self.render(FeatureView()) } + @MainActor func testNotInPerceptionBody_SwiftUIBinding() { struct FeatureView: View { @State var model = Model() @@ -80,6 +86,7 @@ self.render(FeatureView()) } + @MainActor func testInPerceptionBody_SwiftUIBinding() { struct FeatureView: View { @State var model = Model() @@ -92,6 +99,7 @@ self.render(FeatureView()) } + @MainActor func testNotInPerceptionBody_ForEach() { struct FeatureView: View { @State var model = Model( @@ -111,6 +119,7 @@ self.render(FeatureView()) } + @MainActor func testInnerInPerceptionBody_ForEach() { struct FeatureView: View { @State var model = Model( @@ -132,6 +141,7 @@ self.render(FeatureView()) } + @MainActor func testOuterInPerceptionBody_ForEach() { struct FeatureView: View { @State var model = Model( @@ -153,6 +163,7 @@ self.render(FeatureView()) } + @MainActor func testOuterAndInnerInPerceptionBody_ForEach() { struct FeatureView: View { @State var model = Model( @@ -176,6 +187,7 @@ self.render(FeatureView()) } + @MainActor func testNotInPerceptionBody_Sheet() { struct FeatureView: View { @State var model = Model(child: Model()) @@ -190,6 +202,7 @@ self.render(FeatureView()) } + @MainActor func testInnerInPerceptionBody_Sheet() { struct FeatureView: View { @State var model = Model(child: Model()) @@ -206,6 +219,7 @@ self.render(FeatureView()) } + @MainActor func testOuterInPerceptionBody_Sheet() { struct FeatureView: View { @State var model = Model(child: Model()) @@ -222,6 +236,7 @@ self.render(FeatureView()) } + @MainActor func testOuterAndInnerInPerceptionBody_Sheet() { struct FeatureView: View { @State var model = Model(child: Model()) @@ -240,6 +255,7 @@ self.render(FeatureView()) } + @MainActor func testActionClosure() { struct FeatureView: View { @State var model = Model() @@ -252,6 +268,7 @@ self.render(FeatureView()) } + @MainActor func testActionClosure_CallMethodWithArguments() { struct FeatureView: View { @State var model = Model() @@ -268,6 +285,7 @@ self.render(FeatureView()) } + @MainActor func testActionClosure_WithArguments() { struct FeatureView: View { @State var model = Model() @@ -282,6 +300,7 @@ self.render(FeatureView()) } + @MainActor func testActionClosure_WithArguments_ImplicitClosure() { struct FeatureView: View { @State var model = Model() @@ -297,6 +316,7 @@ self.render(FeatureView()) } + @MainActor func testImplicitActionClosure() { struct FeatureView: View { @State var model = Model() @@ -312,6 +332,7 @@ self.render(FeatureView()) } + @MainActor func testRegistrarDisablePerceptionTracking() { struct FeatureView: View { let model = Model() @@ -324,6 +345,7 @@ self.render(FeatureView()) } + @MainActor func testGlobalDisablePerceptionTracking() { let previous = Perception.isPerceptionCheckingEnabled Perception.isPerceptionCheckingEnabled = false @@ -338,6 +360,7 @@ self.render(FeatureView()) } + @MainActor func testParentAccessingChildState_ParentNotObserving_ChildObserving() { struct ChildView: View { let model: Model @@ -367,6 +390,7 @@ self.render(FeatureView()) } + @MainActor func testParentAccessingChildState_ParentObserving_ChildNotObserving() { struct ChildView: View { let model: Model @@ -394,6 +418,7 @@ self.render(FeatureView()) } + @MainActor func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() { struct ChildView: View { let model: Model @@ -421,6 +446,7 @@ self.render(FeatureView()) } + @MainActor func testParentAccessingChildState_ParentObserving_ChildObserving() { struct ChildView: View { let model: Model @@ -450,6 +476,7 @@ self.render(FeatureView()) } + @MainActor func testAccessInOnAppearWithAsyncTask() async throws { @MainActor struct FeatureView: View { @@ -465,6 +492,7 @@ try await Task.sleep(for: .milliseconds(100)) } + @MainActor func testAccessInOnAppearWithAsyncTask_Implicit() async throws { @MainActor struct FeatureView: View { @@ -484,6 +512,7 @@ try await Task.sleep(for: .milliseconds(100)) } + @MainActor func testAccessInTask() async throws { @MainActor struct FeatureView: View { @@ -499,6 +528,7 @@ try await Task.sleep(for: .milliseconds(100)) } + @MainActor func testGeometryReader_WithoutPerceptionTracking() { struct FeatureView: View { let model = Model() @@ -513,6 +543,7 @@ self.render(FeatureView()) } + @MainActor func testGeometryReader_WithProperPerceptionTracking() { struct FeatureView: View { let model = Model() @@ -527,6 +558,7 @@ self.render(FeatureView()) } + @MainActor func testGeometryReader_ComputedProperty_ImproperPerceptionTracking() { struct FeatureView: View { let model = Model() @@ -544,6 +576,7 @@ self.render(FeatureView()) } + @MainActor private func render(_ view: some View) { let image = ImageRenderer(content: view).cgImage _ = image