Skip to content

Commit 6d6f2b4

Browse files
authored
Isolate cancellation in root stores. (#3660)
* Isolate cancellation in root stores. * clean up * wip * fix cancellation id prefix. * wip
1 parent 8e8ce78 commit 6d6f2b4

File tree

7 files changed

+106
-14
lines changed

7 files changed

+106
-14
lines changed

Sources/ComposableArchitecture/Core.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ final class RootCore<Root: Reducer>: Core {
6161
self.reducer = reducer
6262
}
6363
func send(_ action: Root.Action) -> Task<Void, Never>? {
64-
_withoutPerceptionChecking {
65-
_send(action)
66-
}
64+
_withoutPerceptionChecking {
65+
_send(action)
66+
}
6767
}
68-
func _send(_ action: Root.Action) -> Task<Void, Never>? {
68+
private func _send(_ action: Root.Action) -> Task<Void, Never>? {
6969
self.bufferedActions.append(action)
7070
guard !self.isSending else { return nil }
7171

Sources/ComposableArchitecture/Internal/NavigationID.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
@_spi(Reflection) import CasePaths
23

34
extension DependencyValues {
@@ -22,14 +23,18 @@ struct NavigationIDPath: Hashable, Sendable {
2223

2324
var prefixes: [NavigationIDPath] {
2425
(0...self.path.count).map { index in
25-
NavigationIDPath(path: Array(self.path.dropFirst(index)))
26+
NavigationIDPath(path: Array(self.path.prefix(self.path.count - index)))
2627
}
2728
}
2829

2930
func appending(_ element: NavigationID) -> Self {
3031
.init(path: self.path + [element])
3132
}
3233

34+
mutating func append(_ element: NavigationID) {
35+
self.path.append(element)
36+
}
37+
3338
public var id: Self { self }
3439
}
3540

@@ -111,6 +116,12 @@ struct NavigationID: Hashable, @unchecked Sendable {
111116
}
112117
}
113118

119+
init() {
120+
self.kind = .keyPath(\Void.self)
121+
self.identifier = UUID()
122+
self.tag = nil
123+
}
124+
114125
static func == (lhs: Self, rhs: Self) -> Bool {
115126
lhs.kind == rhs.kind
116127
&& lhs.identifier == rhs.identifier

Sources/ComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ extension Reducer {
7676
// specialization defined below from being called, which fuses chained calls.
7777
-> _DependencyKeyWritingReducer<Self>
7878
{
79-
_DependencyKeyWritingReducer(base: self) { $0[keyPath: keyPath] = value }
79+
_DependencyKeyWritingReducer(base: self) {
80+
$0[keyPath: keyPath] = value
81+
}
8082
}
8183

8284
/// Places a value in the reducer's dependencies.
@@ -144,7 +146,9 @@ extension Reducer {
144146
// specialization defined below from being called, which fuses chained calls.
145147
-> _DependencyKeyWritingReducer<Self>
146148
{
147-
_DependencyKeyWritingReducer(base: self) { transform(&$0[keyPath: keyPath]) }
149+
_DependencyKeyWritingReducer(base: self) {
150+
transform(&$0[keyPath: keyPath])
151+
}
148152
}
149153
}
150154

Sources/ComposableArchitecture/Store.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ public final class Store<State, Action> {
176176
) {
177177
let (initialState, reducer, dependencies) = withDependencies(prepareDependencies ?? { _ in }) {
178178
@Dependency(\.self) var dependencies
179-
return (initialState(), reducer(), dependencies)
179+
var updatedDependencies = dependencies
180+
updatedDependencies.navigationIDPath.append(NavigationID())
181+
return (initialState(), reducer(), updatedDependencies)
180182
}
181183
self.init(
182184
initialState: initialState,
@@ -329,7 +331,8 @@ public final class Store<State, Action> {
329331
}
330332

331333
@available(
332-
*, deprecated,
334+
*,
335+
deprecated,
333336
message:
334337
"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"
335338
)

Sources/ComposableArchitecture/TestStore.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ public final class TestStore<State: Equatable, Action> {
541541
let reducer = Dependencies.withDependencies {
542542
prepareDependencies(&$0)
543543
sharedChangeTracker.track(&$0)
544+
$0.navigationIDPath.append(NavigationID())
544545
} operation: {
545546
TestReducer(Reduce(reducer()), initialState: initialState())
546547
}

Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,7 @@ final class PresentationReducerTests: BaseTCATestCase {
11091109
await presentationTask.cancel()
11101110
}
11111111

1112-
func testNavigation_cancelID_parentCancellation() async {
1112+
func testNavigation_cancelID_parentCancellation() async throws {
11131113
struct Grandchild: Reducer {
11141114
struct State: Equatable {}
11151115
enum Action: Equatable {
@@ -1193,17 +1193,15 @@ final class PresentationReducerTests: BaseTCATestCase {
11931193
let store = await TestStore(initialState: Parent.State()) {
11941194
Parent()
11951195
}
1196-
let childPresentationTask = await store.send(.presentChild) {
1196+
await store.send(.presentChild) {
11971197
$0.child = Child.State()
11981198
}
1199-
let grandchildPresentationTask = await store.send(.child(.presented(.presentGrandchild))) {
1199+
await store.send(.child(.presented(.presentGrandchild))) {
12001200
$0.child?.grandchild = Grandchild.State()
12011201
}
12021202
await store.send(.child(.presented(.startButtonTapped)))
12031203
await store.send(.child(.presented(.grandchild(.presented(.startButtonTapped)))))
12041204
await store.send(.stopButtonTapped)
1205-
await grandchildPresentationTask.cancel()
1206-
await childPresentationTask.cancel()
12071205
}
12081206

12091207
func testNavigation_cancelID_parentCancelTwoChildren() async {

Tests/ComposableArchitectureTests/StoreTests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,81 @@ final class StoreTests: BaseTCATestCase {
12131213
}
12141214
}
12151215
}
1216+
1217+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
1218+
@MainActor func testRootStoreCancellationIsolation() async throws {
1219+
let clock = TestClock()
1220+
let store1 = Store(initialState: RootStoreCancellationIsolation.State()) {
1221+
RootStoreCancellationIsolation()
1222+
} withDependencies: {
1223+
$0.continuousClock = clock
1224+
}
1225+
let store2 = Store(initialState: RootStoreCancellationIsolation.State()) {
1226+
RootStoreCancellationIsolation()
1227+
} withDependencies: {
1228+
$0.continuousClock = clock
1229+
}
1230+
store1.send(.tap)
1231+
store2.send(.tap)
1232+
try await Task.sleep(for: .seconds(1))
1233+
store2.send(.cancelButtonTapped)
1234+
await clock.run(timeout: .seconds(1))
1235+
XCTAssertEqual(store1.count, 42)
1236+
XCTAssertEqual(store2.count, 0)
1237+
}
1238+
1239+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
1240+
@MainActor func testRootStoreCancellationIsolation_TestStore() async throws {
1241+
let clock = TestClock()
1242+
let store1 = TestStore(initialState: RootStoreCancellationIsolation.State()) {
1243+
RootStoreCancellationIsolation()
1244+
} withDependencies: {
1245+
$0.continuousClock = clock
1246+
}
1247+
let store2 = TestStore(initialState: RootStoreCancellationIsolation.State()) {
1248+
RootStoreCancellationIsolation()
1249+
} withDependencies: {
1250+
$0.continuousClock = clock
1251+
}
1252+
await store1.send(.tap)
1253+
await store2.send(.tap)
1254+
await store2.send(.cancelButtonTapped)
1255+
await clock.run()
1256+
await store1.receive(\.response) {
1257+
$0.count = 42
1258+
}
1259+
}
1260+
1261+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
1262+
@Reducer struct RootStoreCancellationIsolation {
1263+
@ObservableState struct State: Equatable {
1264+
var count = 0
1265+
}
1266+
enum Action {
1267+
case cancelButtonTapped
1268+
case response(Int)
1269+
case tap
1270+
}
1271+
@Dependency(\.continuousClock) var clock
1272+
enum CancelID { case effect }
1273+
var body: some ReducerOf<Self> {
1274+
Reduce<State, Action> { state, action in
1275+
switch action {
1276+
case .cancelButtonTapped:
1277+
return .cancel(id: CancelID.effect)
1278+
case .response(let value):
1279+
state.count = value
1280+
return .none
1281+
case .tap:
1282+
return .run { send in
1283+
try await clock.sleep(for: .seconds(1))
1284+
await send(.response(42))
1285+
}
1286+
.cancellable(id: CancelID.effect)
1287+
}
1288+
}
1289+
}
1290+
}
12161291
}
12171292

12181293
#if canImport(Testing)

0 commit comments

Comments
 (0)