diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index a0e79259385f..7a38c61d9663 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -120,6 +120,9 @@ public final class Store { private var parentCancellable: AnyCancellable? private let reducer: (inout State, Action) -> Effect private var bufferedActions: [Action] = [] + #if DEBUG + private var initialThread = Thread.current + #endif /// Initializes a store from an initial state, a reducer, and an environment. /// @@ -322,7 +325,6 @@ public final class Store { action fromLocalAction: @escaping (LocalAction) -> Action ) -> AnyPublisher, Never> where P.Output == LocalState, P.Failure == Never { - func extractLocalState(_ state: State) -> LocalState? { var localState: LocalState? _ = toLocalState(Just(state).eraseToAnyPublisher()) @@ -365,7 +367,9 @@ public final class Store { 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 } @@ -382,45 +386,14 @@ public final class Store { 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) } ) @@ -440,4 +413,59 @@ public final class Store { func absurd(_ 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 + } } diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 83e689a42097..630c85c247bb 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -71,7 +71,7 @@ public final class ViewStore: ObservableObject { _ store: Store, 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 diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index f9b3b9ec099a..fdcbc820981c 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -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>, + 50 ) """# )