Skip to content

Commit

Permalink
Preserve @Bindable's binding identity (#56)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
stephencelis authored Apr 4, 2024
1 parent 83fc3d8 commit 520c458
Show file tree
Hide file tree
Showing 4 changed files with 55 additions and 15 deletions.
22 changes: 15 additions & 7 deletions Sources/Perception/Bindable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
@dynamicMemberLookup
@propertyWrapper
public struct Bindable<Value> {
private let binding: UncheckedSendable<Binding<Value>>

/// 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.
Expand All @@ -26,20 +31,23 @@
public subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<Value, Subject>
) -> Binding<Subject> 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.
Expand Down
6 changes: 6 additions & 0 deletions Sources/Perception/Internal/UncheckedSendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
struct UncheckedSendable<Value>: @unchecked Sendable {
let value: Value
init(_ value: Value) {
self.value = value
}
}
7 changes: 0 additions & 7 deletions Sources/Perception/WithPerceptionTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,3 @@ public enum _PerceptionLocals {
@TaskLocal public static var isInPerceptionTracking = false
@TaskLocal public static var skipPerceptionChecking = false
}

private struct UncheckedSendable<A>: @unchecked Sendable {
let value: A
init(_ value: A) {
self.value = value
}
}
35 changes: 34 additions & 1 deletion Tests/PerceptionTests/RuntimeWarningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@
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) {
_ = model.count
}
}

@MainActor
func testNotInPerceptionBody_InSwiftUIBody() {
struct FeatureView: View {
let model = Model()
Expand All @@ -30,6 +32,7 @@
self.render(FeatureView())
}

@MainActor
func testNotInPerceptionBody_InSwiftUIBody_Wrapper() {
struct FeatureView: View {
let model = Model()
Expand All @@ -42,6 +45,7 @@
self.render(FeatureView())
}

@MainActor
func testInPerceptionBody_InSwiftUIBody_Wrapper() {
struct FeatureView: View {
let model = Model()
Expand All @@ -56,6 +60,7 @@
self.render(FeatureView())
}

@MainActor
func testInPerceptionBody_InSwiftUIBody() {
struct FeatureView: View {
let model = Model()
Expand All @@ -68,6 +73,7 @@
self.render(FeatureView())
}

@MainActor
func testNotInPerceptionBody_SwiftUIBinding() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -80,6 +86,7 @@
self.render(FeatureView())
}

@MainActor
func testInPerceptionBody_SwiftUIBinding() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -92,6 +99,7 @@
self.render(FeatureView())
}

@MainActor
func testNotInPerceptionBody_ForEach() {
struct FeatureView: View {
@State var model = Model(
Expand All @@ -111,6 +119,7 @@
self.render(FeatureView())
}

@MainActor
func testInnerInPerceptionBody_ForEach() {
struct FeatureView: View {
@State var model = Model(
Expand All @@ -132,6 +141,7 @@
self.render(FeatureView())
}

@MainActor
func testOuterInPerceptionBody_ForEach() {
struct FeatureView: View {
@State var model = Model(
Expand All @@ -153,6 +163,7 @@
self.render(FeatureView())
}

@MainActor
func testOuterAndInnerInPerceptionBody_ForEach() {
struct FeatureView: View {
@State var model = Model(
Expand All @@ -176,6 +187,7 @@
self.render(FeatureView())
}

@MainActor
func testNotInPerceptionBody_Sheet() {
struct FeatureView: View {
@State var model = Model(child: Model())
Expand All @@ -190,6 +202,7 @@
self.render(FeatureView())
}

@MainActor
func testInnerInPerceptionBody_Sheet() {
struct FeatureView: View {
@State var model = Model(child: Model())
Expand All @@ -206,6 +219,7 @@
self.render(FeatureView())
}

@MainActor
func testOuterInPerceptionBody_Sheet() {
struct FeatureView: View {
@State var model = Model(child: Model())
Expand All @@ -222,6 +236,7 @@
self.render(FeatureView())
}

@MainActor
func testOuterAndInnerInPerceptionBody_Sheet() {
struct FeatureView: View {
@State var model = Model(child: Model())
Expand All @@ -240,6 +255,7 @@
self.render(FeatureView())
}

@MainActor
func testActionClosure() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -252,6 +268,7 @@
self.render(FeatureView())
}

@MainActor
func testActionClosure_CallMethodWithArguments() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -268,6 +285,7 @@
self.render(FeatureView())
}

@MainActor
func testActionClosure_WithArguments() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -282,6 +300,7 @@
self.render(FeatureView())
}

@MainActor
func testActionClosure_WithArguments_ImplicitClosure() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -297,6 +316,7 @@
self.render(FeatureView())
}

@MainActor
func testImplicitActionClosure() {
struct FeatureView: View {
@State var model = Model()
Expand All @@ -312,6 +332,7 @@
self.render(FeatureView())
}

@MainActor
func testRegistrarDisablePerceptionTracking() {
struct FeatureView: View {
let model = Model()
Expand All @@ -324,6 +345,7 @@
self.render(FeatureView())
}

@MainActor
func testGlobalDisablePerceptionTracking() {
let previous = Perception.isPerceptionCheckingEnabled
Perception.isPerceptionCheckingEnabled = false
Expand All @@ -338,6 +360,7 @@
self.render(FeatureView())
}

@MainActor
func testParentAccessingChildState_ParentNotObserving_ChildObserving() {
struct ChildView: View {
let model: Model
Expand Down Expand Up @@ -367,6 +390,7 @@
self.render(FeatureView())
}

@MainActor
func testParentAccessingChildState_ParentObserving_ChildNotObserving() {
struct ChildView: View {
let model: Model
Expand Down Expand Up @@ -394,6 +418,7 @@
self.render(FeatureView())
}

@MainActor
func testParentAccessingChildState_ParentNotObserving_ChildNotObserving() {
struct ChildView: View {
let model: Model
Expand Down Expand Up @@ -421,6 +446,7 @@
self.render(FeatureView())
}

@MainActor
func testParentAccessingChildState_ParentObserving_ChildObserving() {
struct ChildView: View {
let model: Model
Expand Down Expand Up @@ -450,6 +476,7 @@
self.render(FeatureView())
}

@MainActor
func testAccessInOnAppearWithAsyncTask() async throws {
@MainActor
struct FeatureView: View {
Expand All @@ -465,6 +492,7 @@
try await Task.sleep(for: .milliseconds(100))
}

@MainActor
func testAccessInOnAppearWithAsyncTask_Implicit() async throws {
@MainActor
struct FeatureView: View {
Expand All @@ -484,6 +512,7 @@
try await Task.sleep(for: .milliseconds(100))
}

@MainActor
func testAccessInTask() async throws {
@MainActor
struct FeatureView: View {
Expand All @@ -499,6 +528,7 @@
try await Task.sleep(for: .milliseconds(100))
}

@MainActor
func testGeometryReader_WithoutPerceptionTracking() {
struct FeatureView: View {
let model = Model()
Expand All @@ -513,6 +543,7 @@
self.render(FeatureView())
}

@MainActor
func testGeometryReader_WithProperPerceptionTracking() {
struct FeatureView: View {
let model = Model()
Expand All @@ -527,6 +558,7 @@
self.render(FeatureView())
}

@MainActor
func testGeometryReader_ComputedProperty_ImproperPerceptionTracking() {
struct FeatureView: View {
let model = Model()
Expand All @@ -544,6 +576,7 @@
self.render(FeatureView())
}

@MainActor
private func render(_ view: some View) {
let image = ImageRenderer(content: view).cgImage
_ = image
Expand Down

0 comments on commit 520c458

Please sign in to comment.