diff --git a/Sources/ObservableStore/ObservableStore.swift b/Sources/ObservableStore/ObservableStore.swift index be6b197..485fe18 100644 --- a/Sources/ObservableStore/ObservableStore.swift +++ b/Sources/ObservableStore/ObservableStore.swift @@ -352,14 +352,20 @@ where Model: ModelProtocol /// `send(_:)` is run *synchronously*. It is up to you to guarantee it is /// run on main thread when SwiftUI is being used. public func send(_ action: Model.Action) { + DispatchQueue.main.async { @MainActor in + self.sendSync(action) + } + } + + public func sendSync(_ action: Model.Action) { if loggingEnabled { let actionString = String(describing: action) logger.debug("Action: \(actionString, privacy: .public)") } - + // Dispatch action before state change - _actions.send(action) - + self._actions.send(action) + // Create next state update let next = Model.update( state: self.state, @@ -388,10 +394,10 @@ where Model: ModelProtocol self.state = next.state } } - + // Run effects self.subscribe(to: next.fx) - + // Dispatch update after state change self._updates.send(next) } diff --git a/Tests/ObservableStoreTests/BindingTests.swift b/Tests/ObservableStoreTests/BindingTests.swift index 50a5427..65e74cc 100644 --- a/Tests/ObservableStoreTests/BindingTests.swift +++ b/Tests/ObservableStoreTests/BindingTests.swift @@ -59,14 +59,16 @@ final class BindingTests: XCTestCase { view.text = "Foo" view.text = "Bar" - XCTAssertEqual( - store.state.text, - "Bar" - ) - XCTAssertEqual( - store.state.edits, - 2 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.text, + "Bar" + ) + XCTAssertEqual( + store.state.edits, + 2 + ) + } } /// Test creating binding for an address @@ -86,13 +88,15 @@ final class BindingTests: XCTestCase { view.text = "Foo" view.text = "Bar" - XCTAssertEqual( - store.state.text, - "Bar" - ) - XCTAssertEqual( - store.state.edits, - 2 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.text, + "Bar" + ) + XCTAssertEqual( + store.state.edits, + 2 + ) + } } } diff --git a/Tests/ObservableStoreTests/ComponentMappingTests.swift b/Tests/ObservableStoreTests/ComponentMappingTests.swift index 2386d97..c7eaee1 100644 --- a/Tests/ObservableStoreTests/ComponentMappingTests.swift +++ b/Tests/ObservableStoreTests/ComponentMappingTests.swift @@ -140,14 +140,16 @@ class ComponentMappingTests: XCTestCase { send(.setText("Foo")) send(.setText("Bar")) - XCTAssertEqual( - store.state.child.text, - "Bar" - ) - XCTAssertEqual( - store.state.edits, - 2 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.child.text, + "Bar" + ) + XCTAssertEqual( + store.state.edits, + 2 + ) + } } func testKeyedCursorUpdate() throws { @@ -164,11 +166,13 @@ class ComponentMappingTests: XCTestCase { store.send(.keyedChild(action: .setText("BBB"), key: "a")) store.send(.keyedChild(action: .setText("AAA"), key: "a")) - XCTAssertEqual( - store.state.keyedChildren["a"]?.text, - "AAA", - "KeyedCursor updates model at key" - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.keyedChildren["a"]?.text, + "AAA", + "KeyedCursor updates model at key" + ) + } } func testCursorUpdateTransaction() throws { @@ -194,15 +198,17 @@ class ComponentMappingTests: XCTestCase { store.send(.setText("Woo")) store.send(.setText("Woo")) - XCTAssertEqual( - store.state.child.text, - "Woo", - "Cursor updates child model" - ) - XCTAssertEqual( - store.state.edits, - 2 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.child.text, + "Woo", + "Cursor updates child model" + ) + XCTAssertEqual( + store.state.edits, + 2 + ) + } } func testKeyedCursorUpdateMissing() throws { @@ -217,15 +223,18 @@ class ComponentMappingTests: XCTestCase { environment: () ) store.send(.keyedChild(action: .setText("ZZZ"), key: "z")) - XCTAssertEqual( - store.state.keyedChildren.count, - 3, - "KeyedCursor update does nothing if key is missing" - ) - XCTAssertNil( - store.state.keyedChildren["z"], - "KeyedCursor update does nothing if key is missing" - ) + + DispatchQueue.main.async { + XCTAssertEqual( + store.state.keyedChildren.count, + 3, + "KeyedCursor update does nothing if key is missing" + ) + XCTAssertNil( + store.state.keyedChildren["z"], + "KeyedCursor update does nothing if key is missing" + ) + } } func testKeyedCursorUpdateTransaction() throws { @@ -244,9 +253,12 @@ class ComponentMappingTests: XCTestCase { action: .setText("Foo"), environment: () ) - XCTAssertNotNil( - update.transaction, - "Transaction is preserved by cursor" - ) + + DispatchQueue.main.async { + XCTAssertNotNil( + update.transaction, + "Transaction is preserved by cursor" + ) + } } } diff --git a/Tests/ObservableStoreTests/ObservableStoreTests.swift b/Tests/ObservableStoreTests/ObservableStoreTests.swift index fd164d6..1dd72d2 100644 --- a/Tests/ObservableStoreTests/ObservableStoreTests.swift +++ b/Tests/ObservableStoreTests/ObservableStoreTests.swift @@ -94,7 +94,9 @@ final class ObservableStoreTests: XCTestCase { store.send(.increment) - XCTAssertEqual(store.state.count, 1, "state is advanced") + DispatchQueue.main.async { + XCTAssertEqual(store.state.count, 1, "state is advanced") + } } /// Tests that the immediately-completing empty Fx used as the default for @@ -112,7 +114,7 @@ final class ObservableStoreTests: XCTestCase { let expectation = XCTestExpectation( description: "cancellable removed when publisher completes" ) - DispatchQueue.main.async { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { XCTAssertEqual( store.cancellables.count, 0, @@ -120,7 +122,7 @@ final class ObservableStoreTests: XCTestCase { ) expectation.fulfill() } - wait(for: [expectation], timeout: 0.1) + wait(for: [expectation], timeout: 0.2) } /// Tests that immediately-completing Fx get removed from the cancellables. @@ -145,7 +147,7 @@ final class ObservableStoreTests: XCTestCase { let expectation = XCTestExpectation( description: "cancellable removed when publisher completes" ) - DispatchQueue.main.async { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { XCTAssertEqual( store.cancellables.count, 0, @@ -153,7 +155,7 @@ final class ObservableStoreTests: XCTestCase { ) expectation.fulfill() } - wait(for: [expectation], timeout: 0.1) + wait(for: [expectation], timeout: 0.2) } func testAsyncFxRemovedOnComplete() { @@ -229,7 +231,7 @@ final class ObservableStoreTests: XCTestCase { let expectation = XCTestExpectation( description: "publisher does not fire when state does not change" ) - DispatchQueue.main.async { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Publisher should fire twice: once for initial state, // once for state change. XCTAssertEqual( diff --git a/Tests/ObservableStoreTests/ViewStoreTests.swift b/Tests/ObservableStoreTests/ViewStoreTests.swift index b481e05..9ca3c46 100644 --- a/Tests/ObservableStoreTests/ViewStoreTests.swift +++ b/Tests/ObservableStoreTests/ViewStoreTests.swift @@ -107,14 +107,16 @@ final class ViewStoreTests: XCTestCase { viewStore.send(.setText("Foo")) - XCTAssertEqual( - store.state.child.text, - "Foo" - ) - XCTAssertEqual( - store.state.edits, - 1 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.child.text, + "Foo" + ) + XCTAssertEqual( + store.state.edits, + 1 + ) + } } /// Test creating binding for an address @@ -131,13 +133,15 @@ final class ViewStoreTests: XCTestCase { viewStore.send(.setText("Foo")) - XCTAssertEqual( - store.state.child.text, - "Foo" - ) - XCTAssertEqual( - store.state.edits, - 1 - ) + DispatchQueue.main.async { + XCTAssertEqual( + store.state.child.text, + "Foo" + ) + XCTAssertEqual( + store.state.edits, + 1 + ) + } } }