Skip to content

Commit e54ae1d

Browse files
Introduce actions publisher (#22)
This PR introduces `Store.actions`, a publisher which publishes every action passed to `Store.send`. Having a publisher for actions is useful for a number of reasons: - Logging can be subscribed to the publisher to log every action that passes through the store - Views can use [`onReceive`](https://developer.apple.com/documentation/swiftui/view/onreceive(_:perform:)) to react to actions, or wire the store up to other SwiftUI features. Fixes #21
1 parent 5ea1c8b commit e54ae1d

2 files changed

Lines changed: 107 additions & 33 deletions

File tree

Sources/ObservableStore/ObservableStore.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ where Model: ModelProtocol
123123
{
124124
/// Stores cancellables by ID
125125
private(set) var cancellables: [UUID: AnyCancellable] = [:]
126+
/// Private for all actions sent to the store.
127+
private var _actions: PassthroughSubject<Model.Action, Never>
128+
/// Publisher for all actions sent to the store.
129+
public var actions: AnyPublisher<Model.Action, Never> {
130+
_actions.eraseToAnyPublisher()
131+
}
126132
/// Current state.
127133
/// All writes to state happen through actions sent to `Store.send`.
128134
@Published public private(set) var state: Model
@@ -148,6 +154,19 @@ where Model: ModelProtocol
148154
) {
149155
self.state = state
150156
self.environment = environment
157+
self._actions = PassthroughSubject<Model.Action, Never>()
158+
}
159+
160+
/// Initialize and send an initial action to the store.
161+
/// Useful when performing actions once and only once upon creation
162+
/// of the store.
163+
public convenience init(
164+
state: Model,
165+
action: Model.Action,
166+
environment: Model.Environment
167+
) {
168+
self.init(state: state, environment: environment)
169+
self.send(action)
151170
}
152171

153172
/// Subscribe to a publisher of actions, piping them through to
@@ -204,6 +223,8 @@ where Model: ModelProtocol
204223
/// make sure that they join the main thread (e.g. with
205224
/// `.receive(on: DispatchQueue.main)`).
206225
public func send(_ action: Model.Action) {
226+
/// Broadcast action to any outside subscribers
227+
self._actions.send(action)
207228
// Generate next state and effect
208229
let next = Model.update(
209230
state: self.state,

Tests/ObservableStoreTests/ObservableStoreTests.swift

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import SwiftUI
66
final class ObservableStoreTests: XCTestCase {
77
/// App state
88
struct AppModel: ModelProtocol {
9-
enum Action {
9+
enum Action: Hashable {
1010
case increment
1111
case delayIncrement(Double)
1212
case setCount(Int)
1313
case setEditor(Editor)
1414
}
15-
15+
1616
/// Services like API methods go here
1717
struct Environment {
1818
func delayIncrement(
@@ -26,7 +26,7 @@ final class ObservableStoreTests: XCTestCase {
2626
.eraseToAnyPublisher()
2727
}
2828
}
29-
29+
3030
/// State update function
3131
static func update(
3232
state: AppModel,
@@ -53,44 +53,44 @@ final class ObservableStoreTests: XCTestCase {
5353
return Update(state: model)
5454
}
5555
}
56-
57-
struct Editor: Equatable {
58-
struct Input: Equatable {
56+
57+
struct Editor: Hashable {
58+
struct Input: Hashable {
5959
var text: String = ""
6060
var isFocused: Bool = true
6161
}
6262
var input = Input()
6363
}
64-
64+
6565
var count = 0
6666
var editor = Editor()
6767
}
68-
68+
6969
struct SimpleCountView: View {
7070
@Binding var count: Int
71-
71+
7272
var body: some View {
7373
Text("Count: \(count)")
7474
}
7575
}
76-
76+
7777
var cancellables = Set<AnyCancellable>()
78-
78+
7979
override func setUp() {
8080
// Empty cancellables
8181
self.cancellables = Set()
8282
}
83-
83+
8484
func testStateAdvance() throws {
8585
let store = Store(
8686
state: AppModel(),
8787
environment: AppModel.Environment()
8888
)
89-
89+
9090
store.send(.increment)
9191
XCTAssertEqual(store.state.count, 1, "state is advanced")
9292
}
93-
93+
9494
func testBinding() throws {
9595
let store = Store(
9696
state: AppModel(),
@@ -107,7 +107,7 @@ final class ObservableStoreTests: XCTestCase {
107107
XCTAssertEqual(view.count, 2, "binding is set")
108108
XCTAssertEqual(store.state.count, 2, "binding sends action to store")
109109
}
110-
110+
111111
func testDeepBinding() throws {
112112
let store = Store(
113113
state: AppModel(),
@@ -118,16 +118,16 @@ final class ObservableStoreTests: XCTestCase {
118118
get: \.editor,
119119
tag: AppModel.Action.setEditor
120120
)
121-
.input
122-
.text
121+
.input
122+
.text
123123
binding.wrappedValue = "floop"
124124
XCTAssertEqual(
125125
store.state.editor.input.text,
126126
"floop",
127127
"specialized binding sets deep property"
128128
)
129129
}
130-
130+
131131
func testEmptyFxRemovedOnComplete() {
132132
let store = Store(
133133
state: AppModel(),
@@ -149,7 +149,7 @@ final class ObservableStoreTests: XCTestCase {
149149
}
150150
wait(for: [expectation], timeout: 0.1)
151151
}
152-
152+
153153
func testAsyncFxRemovedOnComplete() {
154154
let store = Store(
155155
state: AppModel(),
@@ -170,24 +170,24 @@ final class ObservableStoreTests: XCTestCase {
170170
}
171171
wait(for: [expectation], timeout: 0.5)
172172
}
173-
173+
174174
func testPublishedPropertyFires() throws {
175175
let store = Store(
176176
state: AppModel(),
177177
environment: AppModel.Environment()
178178
)
179-
179+
180180
var count = 0
181181
store.$state
182182
.sink(receiveValue: { _ in
183183
count = count + 1
184184
})
185185
.store(in: &cancellables)
186-
186+
187187
store.send(.increment)
188188
store.send(.increment)
189189
store.send(.increment)
190-
190+
191191
let expectation = XCTestExpectation(
192192
description: "publisher fires when state changes"
193193
)
@@ -201,25 +201,25 @@ final class ObservableStoreTests: XCTestCase {
201201
}
202202
wait(for: [expectation], timeout: 0.2)
203203
}
204-
204+
205205
func testStateOnlySetWhenNotEqual() {
206206
let store = Store(
207207
state: AppModel(),
208208
environment: AppModel.Environment()
209209
)
210-
210+
211211
var count = 0
212212
store.$state
213213
.sink(receiveValue: { _ in
214214
count = count + 1
215215
})
216216
.store(in: &cancellables)
217-
217+
218218
store.send(.setCount(10))
219219
store.send(.setCount(10))
220220
store.send(.setCount(10))
221221
store.send(.setCount(10))
222-
222+
223223
let expectation = XCTestExpectation(
224224
description: "publisher does not fire when state does not change"
225225
)
@@ -235,7 +235,7 @@ final class ObservableStoreTests: XCTestCase {
235235
}
236236
wait(for: [expectation], timeout: 0.2)
237237
}
238-
238+
239239
/// Definition for app to test updates
240240
struct TestUpdateMergeFxState: ModelProtocol {
241241
enum Action {
@@ -246,9 +246,9 @@ final class ObservableStoreTests: XCTestCase {
246246
case setTitle(String)
247247
case setSubtitle(String)
248248
}
249-
249+
250250
struct Environment {}
251-
251+
252252
/// Update function for Fx tests (below)
253253
static func update(
254254
state: Self,
@@ -276,7 +276,7 @@ final class ObservableStoreTests: XCTestCase {
276276
.mergeFx(b)
277277
}
278278
}
279-
279+
280280
var title: String = ""
281281
var subtitle: String = ""
282282
}
@@ -292,11 +292,11 @@ final class ObservableStoreTests: XCTestCase {
292292
subtitle: "subtitle"
293293
)
294294
)
295-
295+
296296
let expectation = XCTestExpectation(
297297
description: "check that update fx are merged"
298298
)
299-
299+
300300
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
301301
XCTAssertEqual(
302302
store.state.title,
@@ -312,4 +312,57 @@ final class ObservableStoreTests: XCTestCase {
312312
}
313313
wait(for: [expectation], timeout: 0.2)
314314
}
315+
316+
func testInitialActionInit() throws {
317+
let store = Store(
318+
state: AppModel(),
319+
action: .increment,
320+
environment: AppModel.Environment()
321+
)
322+
XCTAssertEqual(
323+
store.state.count,
324+
1,
325+
"action was sent to store during init"
326+
)
327+
}
328+
329+
func testActionsPublisher() throws {
330+
let store = Store(
331+
state: AppModel(),
332+
action: .increment,
333+
environment: AppModel.Environment()
334+
)
335+
336+
var actions: [AppModel.Action] = []
337+
store.actions
338+
.sink(receiveValue: { action in
339+
actions.append(action)
340+
})
341+
.store(in: &cancellables)
342+
343+
store.send(.setCount(1))
344+
store.send(.setCount(2))
345+
store.send(.setCount(3))
346+
347+
let expectation = XCTestExpectation(
348+
description: "actions publisher fires for every action"
349+
)
350+
351+
DispatchQueue.main.async {
352+
353+
// Publisher should fire twice: once for initial state,
354+
// once for state change.
355+
XCTAssertEqual(
356+
actions,
357+
[
358+
.setCount(1),
359+
.setCount(2),
360+
.setCount(3)
361+
],
362+
"publisher does not fire when state does not change"
363+
)
364+
expectation.fulfill()
365+
}
366+
wait(for: [expectation], timeout: 0.1)
367+
}
315368
}

0 commit comments

Comments
 (0)