diff --git a/Sources/ComposableArchitecture/Core.swift b/Sources/ComposableArchitecture/Core.swift index e4fe86deb4b1..e8a2e5331105 100644 --- a/Sources/ComposableArchitecture/Core.swift +++ b/Sources/ComposableArchitecture/Core.swift @@ -61,11 +61,11 @@ final class RootCore: Core { self.reducer = reducer } func send(_ action: Root.Action) -> Task? { - _withoutPerceptionChecking { - _send(action) - } + _withoutPerceptionChecking { + _send(action) + } } - func _send(_ action: Root.Action) -> Task? { + private func _send(_ action: Root.Action) -> Task? { self.bufferedActions.append(action) guard !self.isSending else { return nil } diff --git a/Sources/ComposableArchitecture/Internal/NavigationID.swift b/Sources/ComposableArchitecture/Internal/NavigationID.swift index 484493562f93..b29750d64685 100644 --- a/Sources/ComposableArchitecture/Internal/NavigationID.swift +++ b/Sources/ComposableArchitecture/Internal/NavigationID.swift @@ -1,3 +1,4 @@ +import Foundation @_spi(Reflection) import CasePaths extension DependencyValues { @@ -22,7 +23,7 @@ struct NavigationIDPath: Hashable, Sendable { var prefixes: [NavigationIDPath] { (0...self.path.count).map { index in - NavigationIDPath(path: Array(self.path.dropFirst(index))) + NavigationIDPath(path: Array(self.path.prefix(self.path.count - index))) } } @@ -30,6 +31,10 @@ struct NavigationIDPath: Hashable, Sendable { .init(path: self.path + [element]) } + mutating func append(_ element: NavigationID) { + self.path.append(element) + } + public var id: Self { self } } @@ -111,6 +116,12 @@ struct NavigationID: Hashable, @unchecked Sendable { } } + init() { + self.kind = .keyPath(\Void.self) + self.identifier = UUID() + self.tag = nil + } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.kind == rhs.kind && lhs.identifier == rhs.identifier diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift index df0efe60ed65..2bd887e50cea 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift @@ -76,7 +76,9 @@ extension Reducer { // specialization defined below from being called, which fuses chained calls. -> _DependencyKeyWritingReducer { - _DependencyKeyWritingReducer(base: self) { $0[keyPath: keyPath] = value } + _DependencyKeyWritingReducer(base: self) { + $0[keyPath: keyPath] = value + } } /// Places a value in the reducer's dependencies. @@ -144,7 +146,9 @@ extension Reducer { // specialization defined below from being called, which fuses chained calls. -> _DependencyKeyWritingReducer { - _DependencyKeyWritingReducer(base: self) { transform(&$0[keyPath: keyPath]) } + _DependencyKeyWritingReducer(base: self) { + transform(&$0[keyPath: keyPath]) + } } } diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 542869bd918d..a4c169091695 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -176,7 +176,9 @@ public final class Store { ) { let (initialState, reducer, dependencies) = withDependencies(prepareDependencies ?? { _ in }) { @Dependency(\.self) var dependencies - return (initialState(), reducer(), dependencies) + var updatedDependencies = dependencies + updatedDependencies.navigationIDPath.append(NavigationID()) + return (initialState(), reducer(), updatedDependencies) } self.init( initialState: initialState, @@ -329,7 +331,8 @@ public final class Store { } @available( - *, deprecated, + *, + deprecated, message: "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" ) diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index ecc1c1201268..aae8a40d1565 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -541,6 +541,7 @@ public final class TestStore { let reducer = Dependencies.withDependencies { prepareDependencies(&$0) sharedChangeTracker.track(&$0) + $0.navigationIDPath.append(NavigationID()) } operation: { TestReducer(Reduce(reducer()), initialState: initialState()) } diff --git a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift index 2445055c1444..139b605ea6b9 100644 --- a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift @@ -1109,7 +1109,7 @@ final class PresentationReducerTests: BaseTCATestCase { await presentationTask.cancel() } - func testNavigation_cancelID_parentCancellation() async { + func testNavigation_cancelID_parentCancellation() async throws { struct Grandchild: Reducer { struct State: Equatable {} enum Action: Equatable { @@ -1193,17 +1193,15 @@ final class PresentationReducerTests: BaseTCATestCase { let store = await TestStore(initialState: Parent.State()) { Parent() } - let childPresentationTask = await store.send(.presentChild) { + await store.send(.presentChild) { $0.child = Child.State() } - let grandchildPresentationTask = await store.send(.child(.presented(.presentGrandchild))) { + await store.send(.child(.presented(.presentGrandchild))) { $0.child?.grandchild = Grandchild.State() } await store.send(.child(.presented(.startButtonTapped))) await store.send(.child(.presented(.grandchild(.presented(.startButtonTapped))))) await store.send(.stopButtonTapped) - await grandchildPresentationTask.cancel() - await childPresentationTask.cancel() } func testNavigation_cancelID_parentCancelTwoChildren() async { diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 3887f20af7f0..ae6a1dcdd8e5 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -1213,6 +1213,81 @@ final class StoreTests: BaseTCATestCase { } } } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor func testRootStoreCancellationIsolation() async throws { + let clock = TestClock() + let store1 = Store(initialState: RootStoreCancellationIsolation.State()) { + RootStoreCancellationIsolation() + } withDependencies: { + $0.continuousClock = clock + } + let store2 = Store(initialState: RootStoreCancellationIsolation.State()) { + RootStoreCancellationIsolation() + } withDependencies: { + $0.continuousClock = clock + } + store1.send(.tap) + store2.send(.tap) + try await Task.sleep(for: .seconds(1)) + store2.send(.cancelButtonTapped) + await clock.run(timeout: .seconds(1)) + XCTAssertEqual(store1.count, 42) + XCTAssertEqual(store2.count, 0) + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor func testRootStoreCancellationIsolation_TestStore() async throws { + let clock = TestClock() + let store1 = TestStore(initialState: RootStoreCancellationIsolation.State()) { + RootStoreCancellationIsolation() + } withDependencies: { + $0.continuousClock = clock + } + let store2 = TestStore(initialState: RootStoreCancellationIsolation.State()) { + RootStoreCancellationIsolation() + } withDependencies: { + $0.continuousClock = clock + } + await store1.send(.tap) + await store2.send(.tap) + await store2.send(.cancelButtonTapped) + await clock.run() + await store1.receive(\.response) { + $0.count = 42 + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @Reducer struct RootStoreCancellationIsolation { + @ObservableState struct State: Equatable { + var count = 0 + } + enum Action { + case cancelButtonTapped + case response(Int) + case tap + } + @Dependency(\.continuousClock) var clock + enum CancelID { case effect } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .cancelButtonTapped: + return .cancel(id: CancelID.effect) + case .response(let value): + state.count = value + return .none + case .tap: + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.response(42)) + } + .cancellable(id: CancelID.effect) + } + } + } + } } #if canImport(Testing)