Skip to content

Commit 5b78fbc

Browse files
Allow re-entrant actions to be processed (#1352)
* wip * wip * clean up * wip * wip * wip * wip * fix Co-authored-by: Brandon Williams <[email protected]>
1 parent 4c98b43 commit 5b78fbc

File tree

4 files changed

+111
-4
lines changed

4 files changed

+111
-4
lines changed

Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,6 @@ final class LoginCoreTests: XCTestCase {
105105
await store.send(.twoFactorDismissed) {
106106
$0.twoFactor = nil
107107
}
108+
await store.finish()
108109
}
109110
}

Sources/ComposableArchitecture/Store.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,15 +327,24 @@ public final class Store<State, Action> {
327327

328328
self.isSending = true
329329
var currentState = self.state.value
330+
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
330331
defer {
332+
withExtendedLifetime(self.bufferedActions) {
333+
self.bufferedActions.removeAll()
334+
}
331335
self.isSending = false
332336
self.state.value = currentState
337+
// NB: Handle any re-entrant actions
338+
if !self.bufferedActions.isEmpty {
339+
if let task = self.send(
340+
self.bufferedActions.removeLast(), originatingFrom: originatingAction
341+
) {
342+
tasks.wrappedValue.append(task)
343+
}
344+
}
333345
}
334346

335-
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
336-
337347
var index = self.bufferedActions.startIndex
338-
defer { self.bufferedActions = [] }
339348
while index < self.bufferedActions.endIndex {
340349
defer { index += 1 }
341350
let action = self.bufferedActions[index]

Sources/ComposableArchitecture/TestStore.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,19 @@
238238

239239
self.store = Store(
240240
initialState: initialState,
241-
reducer: Reducer<State, TestAction, Void> { [unowned self] state, action, _ in
241+
reducer: Reducer<State, TestAction, Void> { [weak self] state, action, _ in
242+
guard let self = self
243+
else {
244+
XCTFail(
245+
"""
246+
An effect sent an action to the store after the store was deallocated.
247+
""",
248+
file: file,
249+
line: line
250+
)
251+
return .none
252+
}
253+
242254
let effects: Effect<Action, Never>
243255
switch action.origin {
244256
case let .send(scopedAction):
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import Combine
2+
import CombineSchedulers
3+
import ComposableArchitecture
4+
import XCTest
5+
6+
@MainActor
7+
final class CompatibilityTests: XCTestCase {
8+
func testCaseStudy_ReentrantEffect() {
9+
let cancelID = UUID()
10+
11+
struct State: Equatable {}
12+
enum Action: Equatable {
13+
case start
14+
case kickOffAction
15+
case actionSender(OnDeinit)
16+
case stop
17+
18+
var description: String {
19+
switch self {
20+
case .start:
21+
return "start"
22+
case .kickOffAction:
23+
return "kickOffAction"
24+
case .actionSender:
25+
return "actionSender"
26+
case .stop:
27+
return "stop"
28+
}
29+
}
30+
}
31+
let passThroughSubject = PassthroughSubject<Action, Never>()
32+
33+
var handledActions: [String] = []
34+
35+
let reducer = Reducer<State, Action, Void> { state, action, env in
36+
handledActions.append(action.description)
37+
38+
switch action {
39+
case .start:
40+
return passThroughSubject
41+
.eraseToEffect()
42+
.cancellable(id: cancelID)
43+
44+
case .kickOffAction:
45+
return Effect(value: .actionSender(OnDeinit { passThroughSubject.send(.stop) }))
46+
47+
case .actionSender:
48+
return .none
49+
50+
case .stop:
51+
return .cancel(id: cancelID)
52+
}
53+
}
54+
55+
let store = Store(
56+
initialState: .init(),
57+
reducer: reducer,
58+
environment: ()
59+
)
60+
61+
let viewStore = ViewStore(store)
62+
63+
viewStore.send(.start)
64+
viewStore.send(.kickOffAction)
65+
66+
XCTAssertNoDifference(
67+
handledActions,
68+
[
69+
"start",
70+
"kickOffAction",
71+
"actionSender",
72+
"stop",
73+
]
74+
)
75+
}
76+
}
77+
78+
private final class OnDeinit: Equatable {
79+
private let onDeinit: () -> ()
80+
init(onDeinit: @escaping () -> ()) {
81+
self.onDeinit = onDeinit
82+
}
83+
deinit { self.onDeinit() }
84+
static func == (lhs: OnDeinit, rhs: OnDeinit) -> Bool { true }
85+
}

0 commit comments

Comments
 (0)