From 4579a37418a2cd929991b1f3a87809676d1a5417 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 30 Jul 2023 09:20:22 -0700 Subject: [PATCH] Allow chaining into `BindingViewState` Currently, `BindingViewStore`s can only directly derive `BindingViewState` for a view state struct, and then the view store can derive a binding and use dynamic member lookup to pluck out a field for a view. This means potentially exposing view state to far more state than necessary. To prevent this we can add dynamic member lookup to the binding view state itself, which allows a view state struct to chip away any state it doesn't care about. --- .../ComposableArchitecture/SwiftUI/Binding.swift | 7 +++++++ .../Reducers/BindingReducerTests.swift | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 83a98159b7aa..7cf2ad00fedb 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -358,6 +358,7 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt /// bindable in SwiftUI views. /// /// Read for more information. +@dynamicMemberLookup @propertyWrapper public struct BindingViewState { let binding: Binding @@ -376,6 +377,12 @@ public struct BindingViewState { public var projectedValue: Binding { self.binding } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> BindingViewState { + BindingViewState(binding: self.binding[dynamicMember: keyPath]) + } } extension BindingViewState: Equatable where Value: Equatable { diff --git a/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift index 157e9c2e8999..a4e2a04ead54 100644 --- a/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift @@ -84,6 +84,20 @@ final class BindingTests: BaseTCATestCase { XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) } + func testNestedBindingViewState() { + struct ViewState: Equatable { + @BindingViewState var field: String + } + + let store = Store(initialState: BindingTest.State()) { BindingTest() } + + let viewStore = ViewStore(store, observe: { ViewState(field: $0.$nested.field) }) + + viewStore.$field.wrappedValue = "Hello" + + XCTAssertEqual(store.withState { $0.nested.field }, "Hello!") + } + func testBindingActionUpdatesRespectsPatternMatching() async { let testStore = TestStore( initialState: BindingTest.State(nested: BindingTest.State.Nested(field: ""))