Skip to content

Commit 001d0e9

Browse files
Cancel effects when root store deallocates (pointfreeco#3613)
* Cancel effects when root store deinits. * Add test for uncached stores. * wip * wip * wip * wip --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent 8ffc4df commit 001d0e9

File tree

4 files changed

+65
-30
lines changed

4 files changed

+65
-30
lines changed

ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/ComposableArchitecture/RootStore.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ public final class RootStore {
5252
defer { index += 1 }
5353
let action = self.bufferedActions[index] as! Action
5454
let effect = reducer.reduce(into: &currentState, action: action)
55+
let uuid = UUID()
5556

5657
switch effect.operation {
5758
case .none:
5859
break
5960
case let .publisher(publisher):
6061
var didComplete = false
6162
let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
62-
let uuid = UUID()
6363
let effectCancellable = withEscapedDependencies { continuation in
6464
publisher
6565
.receive(on: UIScheduler.shared)
@@ -88,11 +88,13 @@ public final class RootStore {
8888
}
8989
boxedTask.wrappedValue = task
9090
tasks.withValue { $0.append(task) }
91-
self.effectCancellables[uuid] = effectCancellable
91+
self.effectCancellables[uuid] = AnyCancellable {
92+
task.cancel()
93+
}
9294
}
9395
case let .run(priority, operation):
9496
withEscapedDependencies { continuation in
95-
let task = Task(priority: priority) { @MainActor in
97+
let task = Task(priority: priority) { @MainActor [weak self] in
9698
let isCompleted = LockIsolated(false)
9799
defer { isCompleted.setValue(true) }
98100
await operation(
@@ -118,14 +120,18 @@ public final class RootStore {
118120
)
119121
}
120122
if let task = continuation.yield({
121-
self.send(effectAction, originatingFrom: action)
123+
self?.send(effectAction, originatingFrom: action)
122124
}) {
123125
tasks.withValue { $0.append(task) }
124126
}
125127
}
126128
)
129+
self?.effectCancellables[uuid] = nil
127130
}
128131
tasks.withValue { $0.append(task) }
132+
self.effectCancellables[uuid] = AnyCancellable {
133+
task.cancel()
134+
}
129135
}
130136
}
131137
}

Tests/ComposableArchitectureTests/StoreLifetimeTests.swift

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Combine
22
@_spi(Logging) import ComposableArchitecture
33
import XCTest
44

5+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
56
final class StoreLifetimeTests: BaseTCATestCase {
67
@available(*, deprecated)
78
@MainActor
@@ -69,9 +70,6 @@ final class StoreLifetimeTests: BaseTCATestCase {
6970

7071
@MainActor
7172
func testStoreDeinit_RunningEffect() async {
72-
XCTTODO(
73-
"We would like for this to pass, but it requires full deprecation of uncached child stores"
74-
)
7573
Logger.shared.isEnabled = true
7674
let effectFinished = self.expectation(description: "Effect finished")
7775
do {
@@ -99,9 +97,6 @@ final class StoreLifetimeTests: BaseTCATestCase {
9997

10098
@MainActor
10199
func testStoreDeinit_RunningCombineEffect() async {
102-
XCTTODO(
103-
"We would like for this to pass, but it requires full deprecation of uncached child stores"
104-
)
105100
Logger.shared.isEnabled = true
106101
let effectFinished = self.expectation(description: "Effect finished")
107102
do {
@@ -129,28 +124,61 @@ final class StoreLifetimeTests: BaseTCATestCase {
129124
await self.fulfillment(of: [effectFinished], timeout: 0.5)
130125
}
131126
#endif
127+
128+
@MainActor
129+
@available(*, deprecated)
130+
func testUnCachedStores() async {
131+
Logger.shared.isEnabled = true
132+
let clock = TestClock()
133+
let store = Store(initialState: Parent.State()) {
134+
Parent()
135+
} withDependencies: {
136+
$0.continuousClock = clock
137+
}
138+
do {
139+
let child = store.scope(state: { $0.child }, action: { .child($0) })
140+
child.send(.start)
141+
XCTAssertEqual(store.withState(\.child.count), 1)
142+
}
143+
await clock.run()
144+
XCTAssertEqual(store.withState(\.child.count), 2)
145+
}
132146
}
133147

134148
@Reducer
149+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
135150
private struct Child {
136151
struct State: Equatable {
137152
var count = 0
138153
}
139154
enum Action {
140155
case tap
156+
case start
157+
case response
141158
}
159+
@Dependency(\.continuousClock) var clock
142160
var body: some ReducerOf<Self> {
143161
Reduce { state, action in
144162
switch action {
145163
case .tap:
146164
state.count += 1
147165
return .none
166+
case .start:
167+
state.count += 1
168+
return .run { send in
169+
try await clock.sleep(for: .seconds(0))
170+
await send(.response)
171+
}
172+
case .response:
173+
state.count += 1
174+
return .none
148175
}
149176
}
150177
}
151178
}
152179

153180
@Reducer
181+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
154182
private struct Parent {
155183
struct State: Equatable {
156184
var child = Child.State()
@@ -166,6 +194,7 @@ private struct Parent {
166194
}
167195

168196
@Reducer
197+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
169198
private struct Grandparent {
170199
struct State: Equatable {
171200
var child = Parent.State()

0 commit comments

Comments
 (0)