Skip to content

Commit 5ea1c8b

Browse files
Synthesize update(state:actions:environment) (#16)
This PR introduces an alternative approach to composing multiple update functions. Any type that conforms to `ModelProtocol` has a `update(state:actions:environment)` static function synthesized for it. This function can be used to simulate the effect of sending multiple actions in sequence *immediately*, in effect, composing the actions. ``` update( state: state, actions: [ .setEditor( text: detail.entry.body, saveState: detail.saveState ), .presentDetail(true), .requestEditorFocus(false) ], environment: environment ) ``` State is updated immediately, fx are merged, and last transaction wins. Now that we have a way to immediately sequence actions in same state update, we no longer need to run fx on same tick. Joining on main is my preference from an API perspective because it has fewer footguns in implementation and use. #15 caused fx to be run immediately instead of joined on main. The intent was to allow for composing multiple actions by sending up many `Just(.action)` fx. However, - This is verbose to write, and rather "chatty". - It also makes the store implementation less straightforward, since without joining on main, we must check if fx was completed immediately before adding to fx dictionary. Joining on main solves this problem by running the fx on next tick, after the fx has been added to dictionary. - Additionally, it means that off-main-thread fx are required to be joined manually on main to prevent SwiftUI from complaining. ## Breaking changes - Remove Update.pipe. Redundant now. Was never happy with it anyway. It was an inelegant way to accomplish the same thing as `update(state:actions:environment:)`. - Revert fx to be joined on main thread. We join on main with a .default QoS, because fx should be async/never block user interaction.
1 parent b773ae5 commit 5ea1c8b

2 files changed

Lines changed: 159 additions & 30 deletions

File tree

Sources/ObservableStore/ObservableStore.swift

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,37 @@ public protocol ModelProtocol: Equatable {
2323
) -> Update<Self>
2424
}
2525

26+
extension ModelProtocol {
27+
/// Update state through a sequence of actions, merging fx.
28+
/// - State updates happen immediately
29+
/// - Fx are merged
30+
/// - Last transaction wins
31+
/// This function is useful for composing actions, or when dispatching
32+
/// actions down to multiple child components.
33+
/// - Returns an Update that is the result of sequencing actions
34+
public static func update(
35+
state: Self,
36+
actions: [Action],
37+
environment: Environment
38+
) -> Update<Self> {
39+
actions.reduce(
40+
Update(state: state),
41+
{ result, action in
42+
let next = update(
43+
state: result.state,
44+
action: action,
45+
environment: environment
46+
)
47+
return Update(
48+
state: next.state,
49+
fx: result.fx.merge(with: next.fx).eraseToAnyPublisher(),
50+
transaction: next.transaction
51+
)
52+
}
53+
)
54+
}
55+
}
56+
2657
/// Update represents a state change, together with an `Fx` publisher,
2758
/// and an optional `Transaction`.
2859
public struct Update<Model: ModelProtocol> {
@@ -66,27 +97,6 @@ public struct Update<Model: ModelProtocol> {
6697
this.transaction = Transaction(animation: animation)
6798
return this
6899
}
69-
70-
/// Pipe a state through another update function.
71-
/// Allows you to compose multiple update functions together through
72-
/// method chaining.
73-
///
74-
/// - Updates state,
75-
/// - Merges `fx`.
76-
/// - Replaces `transaction` with new `Update` transaction.
77-
///
78-
/// - Returns a new `Update`
79-
public func pipe(
80-
_ through: (Model) -> Self
81-
) -> Self {
82-
let next = through(self.state)
83-
let fx = self.fx.merge(with: next.fx).eraseToAnyPublisher()
84-
return Update(
85-
state: next.state,
86-
fx: fx,
87-
transaction: next.transaction
88-
)
89-
}
90100
}
91101

92102
/// A store is any type that can
@@ -154,24 +164,33 @@ where Model: ModelProtocol
154164
// memory leak.
155165
let id = UUID()
156166

157-
// Did fx complete immediately?
158-
// We use this flag to deal with a race condition where
159-
// an effect can complete before it is added to cancellables,
160-
// meaking receiveCompletion tries to clean it up before it is added.
161-
var didComplete = false
167+
// Receive Fx on main thread. This does two important things:
168+
//
169+
// First, SwiftUI requires that any state mutations that would change
170+
// views happen on the main thread. Receiving on main ensures that
171+
// all fx-driven state transitions happen on main, even if the
172+
// publisher is off-main-thread.
173+
//
174+
// Second, if we didn't schedule receive on main, it would be possible
175+
// for publishers to complete immediately, causing receiveCompletion
176+
// to attempt to remove the publisher from `cancellables` before
177+
// it is added. By scheduling to receive publisher on main,
178+
// we force publisher to complete on next tick, ensuring that it
179+
// is always first added, then removed from `cancellables`.
162180
let cancellable = fx
181+
.receive(
182+
on: DispatchQueue.main,
183+
options: .init(qos: .default)
184+
)
163185
.sink(
164186
receiveCompletion: { [weak self] _ in
165-
didComplete = true
166187
self?.cancellables.removeValue(forKey: id)
167188
},
168189
receiveValue: { [weak self] action in
169190
self?.send(action)
170191
}
171192
)
172-
if !didComplete {
173-
self.cancellables[id] = cancellable
174-
}
193+
self.cancellables[id] = cancellable
175194
}
176195

177196
/// Send an action to the store to update state and generate effects.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//
2+
// UpdateActionsTests.swift
3+
//
4+
// Created by Gordon Brander on 9/14/22.
5+
//
6+
7+
import XCTest
8+
import ObservableStore
9+
import Combine
10+
11+
class UpdateActionsTests: XCTestCase {
12+
enum TestAction {
13+
case increment
14+
case setText(String)
15+
case delayedText(text: String, delay: Double)
16+
case delayedIncrement(delay: Double)
17+
case combo
18+
}
19+
20+
struct TestModel: ModelProtocol {
21+
typealias Action = TestAction
22+
typealias Environment = Void
23+
24+
var count = 0
25+
var text = ""
26+
27+
static func update(
28+
state: TestModel,
29+
action: TestAction,
30+
environment: Void
31+
) -> Update<TestModel> {
32+
switch action {
33+
case .increment:
34+
var model = state
35+
model.count = model.count + 1
36+
return Update(state: model)
37+
.animation(.default)
38+
case .setText(let text):
39+
var model = state
40+
model.text = text
41+
return Update(state: model)
42+
case let .delayedText(text, delay):
43+
let fx: Fx<Action> = Just(
44+
Action.setText(text)
45+
)
46+
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
47+
.eraseToAnyPublisher()
48+
return Update(state: state, fx: fx)
49+
case let .delayedIncrement(delay):
50+
let fx: Fx<Action> = Just(
51+
Action.increment
52+
)
53+
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
54+
.eraseToAnyPublisher()
55+
return Update(state: state, fx: fx)
56+
case .combo:
57+
return update(
58+
state: state,
59+
actions: [
60+
.increment,
61+
.increment,
62+
.delayedIncrement(delay: 0.02),
63+
.delayedText(text: "Test", delay: 0.01),
64+
.increment
65+
],
66+
environment: environment
67+
)
68+
}
69+
}
70+
}
71+
72+
func testUpdateActions() throws {
73+
let store = Store(
74+
state: TestModel(),
75+
environment: ()
76+
)
77+
store.send(.combo)
78+
let expectation = XCTestExpectation(
79+
description: "Autofocus sets editor focus"
80+
)
81+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
82+
XCTAssertEqual(
83+
store.state.count,
84+
4,
85+
"All increments run. Fx merged."
86+
)
87+
XCTAssertEqual(
88+
store.state.text,
89+
"Test",
90+
"Text set"
91+
)
92+
expectation.fulfill()
93+
}
94+
wait(for: [expectation], timeout: 0.2)
95+
}
96+
97+
func testUpdateActionsTransaction() throws {
98+
let next = TestModel.update(
99+
state: TestModel(),
100+
actions: [
101+
.increment,
102+
.increment,
103+
.setText("Foo"),
104+
.increment,
105+
],
106+
environment: ()
107+
)
108+
XCTAssertNotNil(next.transaction, "Last transaction wins")
109+
}
110+
}

0 commit comments

Comments
 (0)