Skip to content
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
98 changes: 63 additions & 35 deletions Sources/ComposableArchitecture/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ public final class Store<State, Action> {
private var parentCancellable: AnyCancellable?
private let reducer: (inout State, Action) -> Effect<Action, Never>
private var bufferedActions: [Action] = []
#if DEBUG
private var initialThread = Thread.current
#endif

/// Initializes a store from an initial state, a reducer, and an environment.
///
Expand Down Expand Up @@ -322,7 +325,6 @@ public final class Store<State, Action> {
action fromLocalAction: @escaping (LocalAction) -> Action
) -> AnyPublisher<Store<LocalState, LocalAction>, Never>
where P.Output == LocalState, P.Failure == Never {

func extractLocalState(_ state: State) -> LocalState? {
var localState: LocalState?
_ = toLocalState(Just(state).eraseToAnyPublisher())
Expand Down Expand Up @@ -365,7 +367,9 @@ public final class Store<State, Action> {
self.publisherScope(state: toLocalState, action: { $0 })
}

func send(_ action: Action) {
func send(_ action: Action, isFromViewStore: Bool = true) {
self.threadCheck(status: .send(action, isFromViewStore: isFromViewStore))

self.bufferedActions.append(action)
guard !self.isSending else { return }

Expand All @@ -382,45 +386,14 @@ public final class Store<State, Action> {

var didComplete = false
let uuid = UUID()

#if DEBUG
let initalThread = Thread.current
initalThread.threadDictionary[uuid] = true
#endif

let effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
#if DEBUG
if Thread.current.threadDictionary[uuid] == nil {
breakpoint(
"""
---
Warning: Store.send

The Store class is not thread-safe, and so all interactions with an instance of Store
(including all of its scopes and derived ViewStores) must be done on the same thread.

\(debugCaseOutput(action)) has produced an Effect that was completed on a different thread \
from the one it was executed on.

Starting thread: \(initalThread)
Final thread: \(Thread.current)

Possible fixes for this are:

* Add a .receive(on:) to the Effect to ensure it completes on this Stores correct thread.
"""
)
}

Thread.current.threadDictionary[uuid] = nil
#endif

self?.threadCheck(status: .effectCompletion(action))
didComplete = true
self?.effectCancellables[uuid] = nil
},
receiveValue: { [weak self] action in
self?.send(action)
self?.send(action, isFromViewStore: false)
}
)

Expand All @@ -440,4 +413,59 @@ public final class Store<State, Action> {
func absurd<A>(_ never: Never) -> A {}
return self.scope(state: { $0 }, action: absurd)
}

private enum ThreadCheckStatus {
case effectCompletion(Action)
case send(Action, isFromViewStore: Bool)
}

@inline(__always)
private func threadCheck(status: ThreadCheckStatus) {
#if DEBUG
guard self.initialThread != Thread.current
else { return }

let message: String
switch status {
case let .effectCompletion(action):
message = """
An effect returned from the action "\(debugCaseOutput(action))" completed on the \
wrong thread. Make sure to use ".receive(on:)" on any effects that execute on background \
threads to receive their output on the initial thread.
"""

case let .send(action, isFromViewStore: true):
message = """
"ViewStore.send(\(debugCaseOutput(action)))" was called on the wrong thread. Make \
sure that "ViewStore.send" is always called on the initial thread.
"""

case let .send(action, isFromViewStore: false):
message = """
An effect emitted the action "\(debugCaseOutput(action))" from the wrong thread. Make sure \
to use ".receive(on:)" on any effects that execute on background threads to receive their \
output on the initial thread.
"""
}

breakpoint(
"""
---
Warning:

The store was interacted with on a thread that is different from the thread the store was \
created on:

\(message)

Initial thread: \(self.initialThread)
Current thread: \(Thread.current)

The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
(including all of its scopes and derived view stores) must be done on the same thread.
---
"""
)
#endif
}
}
2 changes: 1 addition & 1 deletion Sources/ComposableArchitecture/ViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public final class ViewStore<State, Action>: ObservableObject {
_ store: Store<State, Action>,
removeDuplicates isDuplicate: @escaping (State, State) -> Bool
) {
self._send = store.send
self._send = { store.send($0) }
self._state = CurrentValueSubject(store.state.value)

self.viewCancellable = store.state
Expand Down
10 changes: 7 additions & 3 deletions Tests/ComposableArchitectureTests/DebugTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,18 @@ final class DebugTests: XCTestCase {
}

func testBindingAction() {
struct State {
@BindableState var width = 0
}

var dump = ""
customDump(BindingAction.set(\CGSize.width, 50), to: &dump)
customDump(BindingAction.set(\State.$width, 50), to: &dump)
XCTAssertNoDifference(
dump,
#"""
BindingAction.set(
\CGSize.width,
50.0
WritableKeyPath<State, BindableState<Int>>,
50
)
"""#
)
Expand Down