diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift index f46435940ec2..d0cf6deb2e08 100644 --- a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -109,13 +109,15 @@ import SwiftUI public init( path: Binding, StackAction>>, root: () -> R, - @ViewBuilder destination: @escaping (Store) -> Destination + @ViewBuilder destination: @escaping (Store) -> Destination, + fileID: StaticString = #fileID, + line: UInt = #line ) where Data == StackState.PathView, Root == ModifiedContent> { - self.init(path: path[]) { + self.init(path: path[fileID: "\(fileID)", line: line]) { root() .modifier( _NavigationDestinationViewModifier(store: path.wrappedValue, destination: destination) @@ -285,11 +287,40 @@ import SwiftUI } extension Store { - fileprivate subscript() -> StackState.PathView + @_spi(Internals) public subscript( + fileID fileID: String, + line line: UInt + ) -> StackState.PathView where State == StackState, Action == StackAction { get { self.currentState.path } set { let newCount = newValue.count + guard newCount != self.currentState.count else { + runtimeWarn( + """ + SwiftUI wrote to a "NavigationStack" binding at "\(fileID):\(line)" with a path that \ + has the same number of elements that already exist in the store. SwiftUI should only \ + write to this binding with a path that has pushed a new element onto the stack, or \ + popped one or more elements from the stack. + + This usually means the "forEach" has not been integrated with the reducer powering the \ + store, and this reducer is responsible for handling stack actions. + + To fix this, ensure that "forEach" is invoked from the reducer's "body": + + Reduce { state, action in + // ... + } + .forEach(\\.path, action: \\.path) { + Path() + } + + And ensure that every parent reducer is integrated into the root reducer that powers \ + the store. + """ + ) + return + } if newCount > self.currentState.count, let component = newValue.last { self.send(.push(id: component.id, state: component.element)) } else { diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 322b728eaaac..9fd94732b193 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -785,7 +785,7 @@ extension WithViewStore where ViewState: Equatable, Content: View { customDump(self.value, to: &value, maxDepth: 0) runtimeWarn( """ - A binding action sent from a view store \ + A binding action sent from a store \ \(self.context == .bindingState ? "for binding state defined " : "")at \ "\(self.fileID):\(self.line)" was not handled. … diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index e53887924ccf..edf1290bbd3d 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -536,7 +536,6 @@ public final class TestStore { self.timeout = 1 * NSEC_PER_SEC self.sharedChangeTracker = sharedChangeTracker self.useMainSerialExecutor = true - let dismiss = self.reducer.dependencies.dismiss.dismiss self.reducer.store = self } diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index bee2a6c1188f..bce4d3afb92a 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -1,6 +1,6 @@ #if DEBUG import Combine - import ComposableArchitecture + @_spi(Internals) import ComposableArchitecture import XCTest final class RuntimeWarningTests: BaseTCATestCase { @@ -193,7 +193,7 @@ ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ - A binding action sent from a view store for binding state defined at \ + A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … Action: @@ -219,7 +219,7 @@ ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ - A binding action sent from a view store for binding state defined at \ + A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … Action: @@ -229,5 +229,50 @@ """ } } + + #if swift(>=5.9) + @Reducer + struct TestStorePath_NotIntegrated { + @ObservableState + struct State: Equatable { + var path = StackState() + } + enum Action { + case path(StackAction) + } + } + @MainActor + func testStorePath_NotIntegrated() { + let store = Store(initialState: TestStorePath_NotIntegrated.State()) { + TestStorePath_NotIntegrated() + } + + XCTExpectFailure { + store.scope(state: \.path, action: \.path)[fileID: "file.swift", line: 1] = .init() + } issueMatcher: { + $0.compactDescription == """ + SwiftUI wrote to a "NavigationStack" binding at "file.swift:1" with a path that has \ + the same number of elements that already exist in the store. SwiftUI should only write \ + to this binding with a path that has pushed a new element onto the stack, or popped \ + one or more elements from the stack. + + This usually means the "forEach" has not been integrated with the reducer powering the \ + store, and this reducer is responsible for handling stack actions. + + To fix this, ensure that "forEach" is invoked from the reducer's "body": + + Reduce { state, action in + // ... + } + .forEach(\\.path, action: \\.path) { + Path() + } + + And ensure that every parent reducer is integrated into the root reducer that powers \ + the store. + """ + } + } + #endif } #endif