From 04556484f031904242cccbbcf2bed37af1d98550 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 4 Mar 2024 11:28:52 -0500 Subject: [PATCH] Change StoreProtocol to implement `transaction` ...rather than send. `send` becomes an asynchronous main actor isolated protocol extension that guarantees your changes will always happen on the main thread, but offers only a fire-and-forget interface. --- Package.swift | 4 +- Sources/ObservableStore/ObservableStore.swift | 52 +++++++++++++------ Tests/ObservableStoreTests/BindingTests.swift | 2 +- .../ComponentMappingTests.swift | 10 ++-- .../ObservableStoreTests.swift | 28 +++++----- .../ObservableStoreTests/ViewStoreTests.swift | 4 +- 6 files changed, 59 insertions(+), 41 deletions(-) diff --git a/Package.swift b/Package.swift index bce1361..98e8385 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "ObservableStore", - platforms: [.macOS(.v11), .iOS(.v15)], + platforms: [.macOS(.v11), .iOS(.v16)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Sources/ObservableStore/ObservableStore.swift b/Sources/ObservableStore/ObservableStore.swift index be6b197..05b4f3a 100644 --- a/Sources/ObservableStore/ObservableStore.swift +++ b/Sources/ObservableStore/ObservableStore.swift @@ -190,7 +190,20 @@ public protocol StoreProtocol { var state: Model { get } - func send(_ action: Model.Action) -> Void + func transact(_ action: Model.Action) -> Void +} + +extension StoreProtocol { + /// Send an action to the store to update state and generate effects. + /// Any effects generated are fed back into the store. + /// + /// Note: SwiftUI requires that all UI changes happen on main thread. + /// `send(_:)` is run *asynchronously*. + nonisolated public func send(_ action: Model.Action) { + Task { @MainActor in + self.transact(action) + } + } } /// Store is a source of truth for a state. @@ -206,15 +219,15 @@ where Model: ModelProtocol { /// Stores cancellables by ID private(set) var cancellables: [UUID: AnyCancellable] = [:] - + /// Private for all actions sent to the store. private var _actions = PassthroughSubject() - + /// Publisher for all actions sent to the store. public var actions: AnyPublisher { _actions.eraseToAnyPublisher() } - + /// Publisher for updates performed on state private var _updates = PassthroughSubject() @@ -344,14 +357,19 @@ where Model: ModelProtocol ) self.cancellables[id] = cancellable } - + /// Send an action to the store to update state and generate effects. /// Any effects generated are fed back into the store. /// /// Note: SwiftUI requires that all UI changes happen on main thread. - /// `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) { + /// `store.transact(_:)` is run *synchronously*, but is not isolated to the + /// main actor. This is because many SwiftUI APIs like Binding are not + /// isolated to the main actor, yet require synchronous state change. + /// + /// It is recommended you call `store.send(:)` which calls `.transact()` + /// *asynchronously* from a main actor isolated task. This will ensure + /// that all state change happens on the main thread. + public func transact(_ action: Model.Action) { if loggingEnabled { let actionString = String(describing: action) logger.debug("Action: \(actionString, privacy: .public)") @@ -413,7 +431,7 @@ public struct ViewStore: StoreProtocol { /// the value using a closure). Using the same approach as Binding /// offers the most reliable results. private var _get: () -> ViewModel - private var _send: (ViewModel.Action) -> Void + private var _transact: (ViewModel.Action) -> Void /// Initialize a ViewStore from a `get` closure and a `send` closure. /// These closures read from a parent store to provide a type-erased @@ -421,18 +439,18 @@ public struct ViewStore: StoreProtocol { /// model and actions. public init( get: @escaping () -> ViewModel, - send: @escaping (ViewModel.Action) -> Void + transact: @escaping (ViewModel.Action) -> Void ) { self._get = get - self._send = send + self._transact = transact } public var state: ViewModel { self._get() } - public func send(_ action: ViewModel.Action) { - self._send(action) + public func transact(_ action: ViewModel.Action) { + self._transact(action) } } @@ -445,7 +463,7 @@ extension ViewStore { ) { self.init( get: { get(store.state) }, - send: { action in store.send(tag(action)) } + transact: { action in store.transact(tag(action)) } ) } } @@ -607,12 +625,12 @@ extension Binding { /// - Returns a binding suitable for use in a vanilla SwiftUI view. public init( get: @escaping () -> Value, - send: @escaping (Action) -> Void, + transact: @escaping (Action) -> Void, tag: @escaping (Value) -> Action ) { self.init( get: get, - set: { value in send(tag(value)) } + set: { value in transact(tag(value)) } ) } } @@ -624,7 +642,7 @@ extension StoreProtocol { ) -> Binding { Binding( get: { get(self.state) }, - set: { value in self.send(tag(value)) } + set: { value in self.transact(tag(value)) } ) } } diff --git a/Tests/ObservableStoreTests/BindingTests.swift b/Tests/ObservableStoreTests/BindingTests.swift index 50a5427..5f0f7c1 100644 --- a/Tests/ObservableStoreTests/BindingTests.swift +++ b/Tests/ObservableStoreTests/BindingTests.swift @@ -50,7 +50,7 @@ final class BindingTests: XCTestCase { let binding = Binding( get: { store.state.text }, - send: store.send, + transact: store.transact, tag: Action.setText ) diff --git a/Tests/ObservableStoreTests/ComponentMappingTests.swift b/Tests/ObservableStoreTests/ComponentMappingTests.swift index 2386d97..a98c9b4 100644 --- a/Tests/ObservableStoreTests/ComponentMappingTests.swift +++ b/Tests/ObservableStoreTests/ComponentMappingTests.swift @@ -133,7 +133,7 @@ class ComponentMappingTests: XCTestCase { ) let send = Address.forward( - send: store.send, + send: store.transact, tag: ParentChildCursor.default.tag ) @@ -161,8 +161,8 @@ class ComponentMappingTests: XCTestCase { ), environment: () ) - store.send(.keyedChild(action: .setText("BBB"), key: "a")) - store.send(.keyedChild(action: .setText("AAA"), key: "a")) + store.transact(.keyedChild(action: .setText("BBB"), key: "a")) + store.transact(.keyedChild(action: .setText("AAA"), key: "a")) XCTAssertEqual( store.state.keyedChildren["a"]?.text, @@ -191,8 +191,8 @@ class ComponentMappingTests: XCTestCase { state: ParentModel(), environment: () ) - store.send(.setText("Woo")) - store.send(.setText("Woo")) + store.transact(.setText("Woo")) + store.transact(.setText("Woo")) XCTAssertEqual( store.state.child.text, diff --git a/Tests/ObservableStoreTests/ObservableStoreTests.swift b/Tests/ObservableStoreTests/ObservableStoreTests.swift index fd164d6..2af6e7c 100644 --- a/Tests/ObservableStoreTests/ObservableStoreTests.swift +++ b/Tests/ObservableStoreTests/ObservableStoreTests.swift @@ -92,8 +92,8 @@ final class ObservableStoreTests: XCTestCase { environment: AppModel.Environment() ) - store.send(.increment) - + store.transact(.increment) + XCTAssertEqual(store.state.count, 1, "state is advanced") } @@ -139,9 +139,9 @@ final class ObservableStoreTests: XCTestCase { state: AppModel(), environment: AppModel.Environment() ) - store.send(.createEmptyFxThatCompletesImmediately) - store.send(.createEmptyFxThatCompletesImmediately) - store.send(.createEmptyFxThatCompletesImmediately) + store.transact(.createEmptyFxThatCompletesImmediately) + store.transact(.createEmptyFxThatCompletesImmediately) + store.transact(.createEmptyFxThatCompletesImmediately) let expectation = XCTestExpectation( description: "cancellable removed when publisher completes" ) @@ -221,11 +221,11 @@ final class ObservableStoreTests: XCTestCase { }) .store(in: &cancellables) - store.send(.setCount(10)) - store.send(.setCount(10)) - store.send(.setCount(10)) - store.send(.setCount(10)) - + store.transact(.setCount(10)) + store.transact(.setCount(10)) + store.transact(.setCount(10)) + store.transact(.setCount(10)) + let expectation = XCTestExpectation( description: "publisher does not fire when state does not change" ) @@ -357,10 +357,10 @@ final class ObservableStoreTests: XCTestCase { }) .store(in: &cancellables) - store.send(.setCount(1)) - store.send(.setCount(2)) - store.send(.setCount(3)) - + store.transact(.setCount(1)) + store.transact(.setCount(2)) + store.transact(.setCount(3)) + let expectation = XCTestExpectation( description: "actions publisher fires for every action" ) diff --git a/Tests/ObservableStoreTests/ViewStoreTests.swift b/Tests/ObservableStoreTests/ViewStoreTests.swift index b481e05..675e137 100644 --- a/Tests/ObservableStoreTests/ViewStoreTests.swift +++ b/Tests/ObservableStoreTests/ViewStoreTests.swift @@ -105,7 +105,7 @@ final class ViewStoreTests: XCTestCase { tag: ParentChildCursor.default.tag ) - viewStore.send(.setText("Foo")) + viewStore.transact(.setText("Foo")) XCTAssertEqual( store.state.child.text, @@ -129,7 +129,7 @@ final class ViewStoreTests: XCTestCase { tag: ParentChildCursor.default.tag ) - viewStore.send(.setText("Foo")) + viewStore.transact(.setText("Foo")) XCTAssertEqual( store.state.child.text,