Skip to content

Commit 7d59a0b

Browse files
stephencelismluisbrown
authored andcommitted
Allow re-entrant actions to be processed (#1352)
* wip * wip * clean up * wip * wip * wip * wip * fix Co-authored-by: Brandon Williams <[email protected]> (cherry picked from commit 5b78fbcb0583568392762b15a262b3106cfb5185) # Conflicts: # Sources/ComposableArchitecture/Store.swift
1 parent 294237d commit 7d59a0b

File tree

4 files changed

+111
-5
lines changed

4 files changed

+111
-5
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: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,15 +336,24 @@ public final class Store<State, Action> {
336336

337337
self.isSending = true
338338
var currentState = self.state
339+
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
339340
defer {
341+
withExtendedLifetime(self.bufferedActions) {
342+
self.bufferedActions.removeAll()
343+
}
340344
self.isSending = false
341345
self.state = currentState
346+
// NB: Handle any re-entrant actions
347+
if !self.bufferedActions.isEmpty {
348+
if let task = self.send(
349+
self.bufferedActions.removeLast(), originatingFrom: originatingAction
350+
) {
351+
tasks.wrappedValue.append(task)
352+
}
353+
}
342354
}
343355

344-
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
345-
346356
var index = self.bufferedActions.startIndex
347-
defer { self.bufferedActions = [] }
348357
while index < self.bufferedActions.endIndex {
349358
defer { index += 1 }
350359
let action = self.bufferedActions[index]
@@ -582,7 +591,7 @@ private struct Scope<RootState, RootAction>: AnyScope {
582591
action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction
583592
) -> Store<RescopedState, RescopedAction> {
584593
let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction
585-
594+
586595
var isSending = false
587596
let rescopedStore = Store<RescopedState, RescopedAction>(
588597
initialState: toRescopedState(scopedStore.state),

Sources/ComposableArchitecture/TestStore.swift

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

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

0 commit comments

Comments
 (0)