diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md index c5d296c43290..d28b12816dff 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md @@ -115,7 +115,7 @@ struct InventoryView: View { ``` > Note: We use SwiftUI's `@Bindable` property wrapper to produce a binding to a store, which can be -> further scoped using ``SwiftUI/Binding/scope(state:action:)-4mj4d``. +> further scoped using ``SwiftUI/Binding/scope(state:action:fileID:line:)``. With those few steps completed the domains and views of the parent and child features are now integrated together, and when the `addItem` state flips to a non-`nil` value the sheet will be @@ -269,8 +269,8 @@ struct InventoryView: View { } ``` -And then in the `body` of the view you can use the ``SwiftUI/Binding/scope(state:action:)-4mj4d`` -operator to derive bindings from `$store`: +And then in the `body` of the view you can use the +``SwiftUI/Binding/scope(state:action:fileID:line:)`` operator to derive bindings from `$store`: ```swift var body: some View { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md index 72f0f117f82e..3ef99676c7e9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md @@ -29,7 +29,7 @@ ### Scoping store bindings -- ``SwiftUI/Binding/scope(state:action:)-4mj4d`` +- ``SwiftUI/Binding/scope(state:action:fileID:line:)`` - ``SwiftUI/Binding/scope(state:action:)-35r82`` ### Combine integration diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md index 4c39dfc0ff19..e9f07780f97f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md @@ -17,7 +17,7 @@ designed with SwiftUI in mind, and comes with many powerful tools to integrate i ### Presentation -- ``SwiftUI/Binding/scope(state:action:)-4mj4d`` +- ``SwiftUI/Binding/scope(state:action:fileID:line:)`` ### Navigation stacks and links diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/04-PresentingSyncUpForm/PresentingSyncUpForm.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/04-PresentingSyncUpForm/PresentingSyncUpForm.tutorial index 4ca7f5b37695..3418069216fb 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/04-PresentingSyncUpForm/PresentingSyncUpForm.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/04-PresentingSyncUpForm/PresentingSyncUpForm.tutorial @@ -112,8 +112,8 @@ Luckily the library comes with the tools necessary. Just as there is a scoping operation on stores for focusing on sub-domains of a parent domain, there is also a scope on _bindings_ of - stores for doing the same: ``SwiftUI/Binding/scope(state:action:)-4mj4d``. This tool can be - used to derive a binding that is appropriate to pass to `sheet(item:)`. + stores for doing the same: ``SwiftUI/Binding/scope(state:action:fileID:line:)``. This tool can + be used to derive a binding that is appropriate to pass to `sheet(item:)`. @Step { Since we want to derive bindings from the store we need to decorate the property in the view @@ -127,8 +127,8 @@ } @Step { - Use the ``SwiftUI/Binding/scope(state:action:)-4mj4d`` operator on `$store` to focus the - binding to the presentation domain of the `SyncUpForm`. The `sheet(item:)` modifier will + Use the ``SwiftUI/Binding/scope(state:action:fileID:line:)`` operator on `$store` to focus + the binding to the presentation domain of the `SyncUpForm`. The `sheet(item:)` modifier will hand the trailing closure a `StoreOf`, and that is exactly what can be handed to the `SyncUpFormView`. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/06-SyncUpDetail/EditingAndDeletingSyncUp.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/06-SyncUpDetail/EditingAndDeletingSyncUp.tutorial index 2ada9d7f5e9f..b25ce21a207f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/06-SyncUpDetail/EditingAndDeletingSyncUp.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/BuildingSyncUps/06-SyncUpDetail/EditingAndDeletingSyncUp.tutorial @@ -70,7 +70,7 @@ @Step { At the very bottom of the view use the `sheet(item:)` modifier by deriving a binding to the - `SyncUpForm` domain using ``SwiftUI/Binding/scope(state:action:)-4mj4d``. + `SyncUpForm` domain using ``SwiftUI/Binding/scope(state:action:fileID:line:)``. @Code(name: "SyncUpDetail.swift", file: EditingAndDeletingSyncUp-01-code-0006.swift) } diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift index 53659ba0ae23..931ec559984a 100644 --- a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -70,7 +70,7 @@ import SwiftUI extension SwiftUI.Bindable { /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. /// - /// See ``SwiftUI/Binding/scope(state:action:)-4mj4d`` defined on `Binding` for more + /// See ``SwiftUI/Binding/scope(state:action:fileID:line:)`` defined on `Binding` for more /// information. public func scope( state: KeyPath>, @@ -88,7 +88,7 @@ import SwiftUI extension Perception.Bindable { /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. /// - /// See ``SwiftUI/Binding/scope(state:action:)-4mj4d`` defined on `Binding` for more + /// See ``SwiftUI/Binding/scope(state:action:fileID:line:)`` defined on `Binding` for more /// information. public func scope( state: KeyPath>, diff --git a/Sources/ComposableArchitecture/Observation/Store+Observation.swift b/Sources/ComposableArchitecture/Observation/Store+Observation.swift index 3b9c420710d1..df56e0308d3a 100644 --- a/Sources/ComposableArchitecture/Observation/Store+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Store+Observation.swift @@ -152,10 +152,18 @@ /// - Returns: A binding of an optional child store. public func scope( state: KeyPath, - action: CaseKeyPath> + action: CaseKeyPath>, + fileID: StaticString = #fileID, + line: UInt = #line ) -> Binding?> where Value == Store { - self[state: state, action: action] + self[ + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: "\(fileID)", + line: line + ] } } @@ -209,10 +217,18 @@ /// - Returns: A binding of an optional child store. public func scope( state: KeyPath, - action: CaseKeyPath> + action: CaseKeyPath>, + fileID: StaticString = #fileID, + line: UInt = #line ) -> Binding?> where Value == Store { - self[state: state, action: action] + self[ + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: "\(fileID)", + line: line + ] } } @@ -269,18 +285,29 @@ /// - Returns: A binding of an optional child store. public func scope( state: KeyPath, - action: CaseKeyPath> + action: CaseKeyPath>, + fileID: StaticString = #fileID, + line: UInt = #line ) -> Binding?> where Value == Store { - self[state: state, action: action] + self[ + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: "\(fileID)", + line: line + ] } } extension Store where State: ObservableState { - fileprivate subscript( + @_spi(Internals) + public subscript( state state: KeyPath, action action: CaseKeyPath>, - isInViewBody isInViewBody: Bool = _isInPerceptionTracking + isInViewBody isInViewBody: Bool, + fileID fileID: String, + line line: UInt ) -> Store? { get { #if DEBUG && !os(visionOS) @@ -294,6 +321,30 @@ set { if newValue == nil, self.state[keyPath: state] != nil, !self._isInvalidated() { self.send(action(.dismiss)) + if self.state[keyPath: state] != nil { + runtimeWarn( + """ + SwiftUI dismissed a view through a binding at "\(fileID):\(line)", but the store \ + destination wasn't set to "nil". + + This usually means an "ifLet" has not been integrated with the reducer powering the \ + store, and this reducer is responsible for handling presentation actions. + + To fix this, ensure that "ifLet" is invoked from the reducer's "body": + + Reduce { state, action in + // ... + } + .ifLet(\\.destination, action: \\.destination) { + Destination() + } + + And ensure that every parent reducer is integrated into the root reducer that powers \ + the store. + """ + ) + return + } } } } diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index bce4d3afb92a..99d2044cf7e3 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -273,6 +273,57 @@ """ } } + + @Reducer + struct TestStoreDestination_NotIntegrated { + @Reducer + struct Destination {} + @ObservableState + struct State: Equatable { + @Presents var destination: Destination.State? + } + enum Action { + case destination(PresentationAction) + } + } + @MainActor + func testStoreDestination_NotIntegrated() { + let store = Store( + initialState: TestStoreDestination_NotIntegrated.State(destination: .init()) + ) { + TestStoreDestination_NotIntegrated() + } + + XCTExpectFailure { + store[ + state: \.destination, + action: \.destination, + isInViewBody: false, + fileID: "file.swift", + line: 1 + ] = nil + } issueMatcher: { + $0.compactDescription == """ + SwiftUI dismissed a view through a binding at "file.swift:1", but the store \ + destination wasn't set to "nil". + + This usually means an "ifLet" has not been integrated with the reducer powering the \ + store, and this reducer is responsible for handling presentation actions. + + To fix this, ensure that "ifLet" is invoked from the reducer's "body": + + Reduce { state, action in + // ... + } + .ifLet(\\.destination, action: \\.destination) { + Destination() + } + + And ensure that every parent reducer is integrated into the root reducer that powers \ + the store. + """ + } + } #endif } #endif