Skip to content

Commit 9a3af3f

Browse files
diederichmluisbrown
authored andcommitted
allow processing of sync actions resulting from state update (#1360)
* allow processing of sync actions resulting from state update Sending an action into the system synchronously from a ViewStore's publisher can result in state updates publishing the wrong, old, value. This PR fixes it by keeping the `Store` in `sending` state while we publish state updates. Once that's done, we check if there were any new actions coming in, in which case we feed them back and start processing again. * Clean up and clarify behavior Co-authored-by: Stephen Celis <[email protected]> (cherry picked from commit 0855c9952549173fc7c655fcee31ffbb709e2dac) # Conflicts: # Sources/ComposableArchitecture/Store.swift
1 parent 04c9e84 commit 9a3af3f

File tree

2 files changed

+70
-9
lines changed

2 files changed

+70
-9
lines changed

Sources/ComposableArchitecture/Store.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,26 @@ public final class Store<State, Action> {
338338
var currentState = self.state
339339
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
340340
defer {
341+
// NB: Handle any potential re-entrant actions.
342+
//
343+
// 1. After all buffered actions have been processed, we clear them out, but this can lead to
344+
// re-entrant actions if a cleared action holds onto an object that sends an additional
345+
// action during "deinit".
346+
//
347+
// We use "withExtendedLifetime" to prevent simultaneous access exceptions for this case,
348+
// which otherwise would try to append an action while removal is still in-process.
341349
withExtendedLifetime(self.bufferedActions) {
342350
self.bufferedActions.removeAll()
343351
}
344-
self.isSending = false
352+
// 2. Updating state can also lead to re-entrant actions if the emission of a downstream store
353+
// publisher sends an action back into the store.
354+
//
355+
// We update "state" _before_ flipping "isSending" to false to ensure these actions are
356+
// appended to the buffer and not processed immediately.
345357
self.state = currentState
346-
// NB: Handle any re-entrant actions
358+
self.isSending = false
359+
// Should either of the above steps send re-entrant actions back into the store, we handle
360+
// them recursively.
347361
if !self.bufferedActions.isEmpty {
348362
if let task = self.send(
349363
self.bufferedActions.removeLast(), originatingFrom: originatingAction
@@ -374,24 +388,24 @@ public final class Store<State, Action> {
374388
}
375389
},
376390
completed: { [weak self] in
377-
self?.threadCheck(status: .effectCompletion(action))
378-
boxedTask.wrappedValue?.cancel()
379-
didComplete = true
391+
self?.threadCheck(status: .effectCompletion(action))
392+
boxedTask.wrappedValue?.cancel()
393+
didComplete = true
380394
self?.effectDisposables.removeValue(forKey: uuid)?.dispose()
381-
},
395+
},
382396
interrupted: { [weak self] in
383397
boxedTask.wrappedValue?.cancel()
384398
didComplete = true
385399
self?.effectDisposables.removeValue(forKey: uuid)?.dispose()
386-
}
400+
}
387401
)
388402

389403
let effectDisposable = CompositeDisposable()
390404
effectDisposable += producer.start(observer)
391405
effectDisposable += AnyDisposable { [weak self] in
392406
self?.threadCheck(status: .effectCompletion(action))
393407
self?.effectDisposables.removeValue(forKey: uuid)?.dispose()
394-
}
408+
}
395409

396410
if !didComplete {
397411
let task = Task<Void, Never> { @MainActor in

Tests/ComposableArchitectureTests/CompatibilityTests.swift

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import XCTest
44

55
@MainActor
66
final class CompatibilityTests: XCTestCase {
7-
func testCaseStudy_ReentrantEffect() {
7+
func testCaseStudy_ReentrantActionsFromBuffer() {
88
let cancelID = UUID()
99

1010
struct State: Equatable {}
@@ -72,6 +72,53 @@ final class CompatibilityTests: XCTestCase {
7272
]
7373
)
7474
}
75+
76+
func testCaseStudy_ReentrantActionsFromProducer() {
77+
struct State: Equatable {
78+
var city: String
79+
var country: String
80+
}
81+
82+
enum Action: Equatable {
83+
case updateCity(String)
84+
case updateCountry(String)
85+
}
86+
87+
let reducer = Reducer<State, Action, Void> { state, action, _ in
88+
switch action {
89+
case let .updateCity(city):
90+
state.city = city
91+
return .none
92+
case let .updateCountry(country):
93+
state.country = country
94+
return .none
95+
}
96+
}
97+
98+
let store = Store(
99+
initialState: State(city: "New York", country: "USA"),
100+
reducer: reducer,
101+
environment: ()
102+
)
103+
let viewStore = ViewStore(store)
104+
105+
viewStore.produced.city
106+
.startWithValues { city in
107+
if city == "London" {
108+
viewStore.send(.updateCountry("UK"))
109+
}
110+
}
111+
112+
var countryUpdates = [String]()
113+
viewStore.produced.country
114+
.startWithValues { country in
115+
countryUpdates.append(country)
116+
}
117+
118+
viewStore.send(.updateCity("London"))
119+
120+
XCTAssertEqual(countryUpdates, ["USA", "UK"])
121+
}
75122
}
76123

77124
private final class OnDeinit: Equatable {

0 commit comments

Comments
 (0)