Skip to content

allow processing of sync actions resulting from state update #1360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions Sources/ComposableArchitecture/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,26 @@ public final class Store<State, Action> {
var currentState = self.state.value
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
defer {
// NB: Handle any potential re-entrant actions.
//
// 1. After all buffered actions have been processed, we clear them out, but this can lead to
// re-entrant actions if a cleared action holds onto an object that sends an additional
// action during "deinit".
//
// We use "withExtendedLifetime" to prevent simultaneous access exceptions for this case,
// which otherwise would try to append an action while removal is still in-process.
withExtendedLifetime(self.bufferedActions) {
self.bufferedActions.removeAll()
}
self.isSending = false
// 2. Updating state can also lead to re-entrant actions if the emission of a downstream store
// publisher sends an action back into the store.
//
// We update "state" _before_ flipping "isSending" to false to ensure these actions are
// appended to the buffer and not processed immediately.
self.state.value = currentState
// NB: Handle any re-entrant actions
self.isSending = false
// Should either of the above steps send re-entrant actions back into the store, we handle
// them recursively.
if !self.bufferedActions.isEmpty {
if let task = self.send(
self.bufferedActions.removeLast(), originatingFrom: originatingAction
Expand Down
53 changes: 52 additions & 1 deletion Tests/ComposableArchitectureTests/CompatibilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import XCTest

@MainActor
final class CompatibilityTests: XCTestCase {
func testCaseStudy_ReentrantEffect() {
func testCaseStudy_ReentrantActionsFromBuffer() {
let cancelID = UUID()

struct State: Equatable {}
Expand Down Expand Up @@ -74,6 +74,57 @@ final class CompatibilityTests: XCTestCase {
]
)
}

func testCaseStudy_ReentrantActionsFromPublisher() {
struct State: Equatable {
var city: String
var country: String
}

enum Action: Equatable {
case updateCity(String)
case updateCountry(String)
}

let reducer = Reducer<State, Action, Void> { state, action, _ in
switch action {
case let .updateCity(city):
state.city = city
return .none
case let .updateCountry(country):
state.country = country
return .none
}
}

let store = Store(
initialState: State(city: "New York", country: "USA"),
reducer: reducer,
environment: ()
)
let viewStore = ViewStore(store)

var cancellables: Set<AnyCancellable> = []

viewStore.publisher.city
.sink { city in
if city == "London" {
viewStore.send(.updateCountry("UK"))
}
}
.store(in: &cancellables)

var countryUpdates = [String]()
viewStore.publisher.country
.sink { country in
countryUpdates.append(country)
}
.store(in: &cancellables)

viewStore.send(.updateCity("London"))

XCTAssertEqual(countryUpdates, ["USA", "UK"])
}
}

private final class OnDeinit: Equatable {
Expand Down