Skip to content

Commit e648004

Browse files
Make update a protocol (fixup) (#43)
1 parent c7af576 commit e648004

File tree

5 files changed

+161
-188
lines changed

5 files changed

+161
-188
lines changed

Sources/ObservableStore/ObservableStore.swift

Lines changed: 149 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import Foundation
77
import Combine
88
import SwiftUI
9+
import os
910

1011
/// Fx is a publisher that publishes actions and never fails.
1112
public typealias Fx<Action> = AnyPublisher<Action, Never>
@@ -16,11 +17,15 @@ public protocol ModelProtocol: Equatable {
1617
associatedtype Action
1718
associatedtype Environment
1819

20+
associatedtype UpdateType: UpdateProtocol where
21+
UpdateType.Model == Self,
22+
UpdateType.Action == Self.Action
23+
1924
static func update(
2025
state: Self,
2126
action: Action,
2227
environment: Environment
23-
) -> Update<Self>
28+
) -> UpdateType
2429
}
2530

2631
extension ModelProtocol {
@@ -35,16 +40,16 @@ extension ModelProtocol {
3540
state: Self,
3641
actions: [Action],
3742
environment: Environment
38-
) -> Update<Self> {
43+
) -> UpdateType {
3944
actions.reduce(
40-
Update(state: state),
45+
UpdateType(state: state),
4146
{ result, action in
4247
let next = update(
4348
state: result.state,
4449
action: action,
4550
environment: environment
4651
)
47-
return Update(
52+
return UpdateType(
4853
state: next.state,
4954
fx: result.fx.merge(with: next.fx).eraseToAnyPublisher(),
5055
transaction: next.transaction
@@ -74,70 +79,66 @@ extension ModelProtocol {
7479
state: Self,
7580
action viewAction: ViewModel.Action,
7681
environment: ViewModel.Environment
77-
) -> Update<Self> {
82+
) -> UpdateType {
7883
// If getter returns nil (as in case of a list item that no longer
7984
// exists), do nothing.
8085
guard let inner = get(state) else {
81-
return Update(state: state)
86+
return UpdateType(state: state)
8287
}
8388
let next = ViewModel.update(
8489
state: inner,
8590
action: viewAction,
8691
environment: environment
8792
)
88-
return Update(
93+
return UpdateType(
8994
state: set(state, next.state),
9095
fx: next.fx.map(tag).eraseToAnyPublisher(),
9196
transaction: next.transaction
9297
)
9398
}
9499
}
95100

96-
/// Update represents a state change, together with an `Fx` publisher,
101+
/// `UpdateProtocol` represents a state change, together with an `Fx` publisher,
97102
/// and an optional `Transaction`.
98-
public struct Update<Model: ModelProtocol> {
99-
/// `State` for this update
100-
public var state: Model
101-
/// `Fx` for this update.
102-
/// Default is an `Empty` publisher (no effects)
103-
public var fx: Fx<Model.Action>
104-
/// The transaction that should be set during this update.
105-
/// Store uses this value to set the transaction while updating state,
106-
/// allowing you to drive explicit animations from your update function.
107-
/// If left `nil`, store will defer to the global transaction
108-
/// for this state update.
109-
/// See https://developer.apple.com/documentation/swiftui/transaction
110-
public var transaction: Transaction?
111-
112-
public init(
103+
public protocol UpdateProtocol {
104+
associatedtype Model
105+
associatedtype Action
106+
107+
init(
113108
state: Model,
114-
fx: Fx<Model.Action>,
109+
fx: Fx<Action>,
115110
transaction: Transaction?
116-
) {
117-
self.state = state
118-
self.fx = fx
119-
self.transaction = transaction
120-
}
111+
)
121112

113+
var state: Model { get set }
114+
var fx: Fx<Action> { get set }
115+
var transaction: Transaction? { get set }
116+
}
117+
118+
extension UpdateProtocol {
122119
public init(state: Model, animation: Animation? = nil) {
123-
self.state = state
124-
self.fx = Empty(completeImmediately: true).eraseToAnyPublisher()
125-
self.transaction = Transaction(animation: animation)
120+
self.init(
121+
state: state,
122+
fx: Empty(completeImmediately: true).eraseToAnyPublisher(),
123+
transaction: Transaction(animation: animation)
124+
)
126125
}
127-
126+
128127
public init(
129128
state: Model,
130-
fx: Fx<Model.Action>,
129+
fx: Fx<Action>,
131130
animation: Animation? = nil
132131
) {
133-
self.state = state
134-
self.fx = fx
135-
self.transaction = Transaction(animation: animation)
132+
self.init(
133+
state: state,
134+
fx: fx,
135+
transaction: Transaction(animation: animation)
136+
)
136137
}
137-
138+
138139
/// Merge existing fx together with new fx.
139140
/// - Returns a new `Update`
140-
public func mergeFx(_ fx: Fx<Model.Action>) -> Update<Model> {
141+
public func mergeFx(_ fx: Fx<Action>) -> Self {
141142
var this = self
142143
this.fx = self.fx.merge(with: fx).eraseToAnyPublisher()
143144
return this
@@ -153,6 +154,34 @@ public struct Update<Model: ModelProtocol> {
153154
}
154155
}
155156

157+
/// Concrete implementation of `UpdateProtocol`.
158+
/// Update represents a state change, together with an `Fx` publisher,
159+
/// and an optional `Transaction`.
160+
public struct Update<Model: ModelProtocol>: UpdateProtocol {
161+
/// `State` for this update
162+
public var state: Model
163+
/// `Fx` for this update.
164+
/// Default is an `Empty` publisher (no effects)
165+
public var fx: Fx<Model.Action>
166+
/// The transaction that should be set during this update.
167+
/// Store uses this value to set the transaction while updating state,
168+
/// allowing you to drive explicit animations from your update function.
169+
/// If left `nil`, store will defer to the global transaction
170+
/// for this state update.
171+
/// See https://developer.apple.com/documentation/swiftui/transaction
172+
public var transaction: Transaction?
173+
174+
public init(
175+
state: Model,
176+
fx: Fx<Model.Action>,
177+
transaction: Transaction?
178+
) {
179+
self.state = state
180+
self.fx = fx
181+
self.transaction = transaction
182+
}
183+
}
184+
156185
/// A store is any type that can
157186
/// - get a state
158187
/// - send actions
@@ -175,17 +204,41 @@ public protocol StoreProtocol {
175204
public final class Store<Model>: ObservableObject, StoreProtocol
176205
where Model: ModelProtocol
177206
{
178-
/// Stores cancellables by ID
179-
private(set) var cancellables: [UUID: AnyCancellable] = [:]
207+
/// Cancellable for fx subscription.
208+
private var cancelFx: AnyCancellable?
209+
180210
/// Private for all actions sent to the store.
181-
private var _actions: PassthroughSubject<Model.Action, Never>
211+
private var _actions = PassthroughSubject<Model.Action, Never>()
212+
182213
/// Publisher for all actions sent to the store.
183214
public var actions: AnyPublisher<Model.Action, Never> {
184215
_actions.eraseToAnyPublisher()
185216
}
217+
218+
/// Source publisher for batches of fx modeled as publishers.
219+
private var _fxBatches = PassthroughSubject<Fx<Model.Action>, Never>()
220+
221+
/// `fx` represents a flat stream of actions from all fx publishers.
222+
private var fx: AnyPublisher<Model.Action, Never> {
223+
_fxBatches
224+
.flatMap({ publisher in publisher })
225+
.receive(on: DispatchQueue.main)
226+
.eraseToAnyPublisher()
227+
}
228+
229+
/// Publisher for updates performed on state
230+
private var _updates = PassthroughSubject<Model.UpdateType, Never>()
231+
232+
/// Publisher for updates performed on state.
233+
/// `updates` is guaranteed to fire after the state has changed.
234+
public var updates: AnyPublisher<Model.UpdateType, Never> {
235+
_updates.eraseToAnyPublisher()
236+
}
237+
186238
/// Current state.
187239
/// All writes to state happen through actions sent to `Store.send`.
188240
@Published public private(set) var state: Model
241+
189242
/// Environment, which typically holds references to outside information,
190243
/// such as API methods.
191244
///
@@ -202,24 +255,47 @@ where Model: ModelProtocol
202255
/// app is stopped.
203256
public var environment: Model.Environment
204257

258+
/// Logger to log actions sent to store.
259+
private var logger: Logger
260+
/// Should log?
261+
var loggingEnabled: Bool
262+
205263
public init(
206264
state: Model,
207-
environment: Model.Environment
265+
environment: Model.Environment,
266+
loggingEnabled: Bool = false,
267+
logger: Logger? = nil
208268
) {
209269
self.state = state
210270
self.environment = environment
211-
self._actions = PassthroughSubject<Model.Action, Never>()
271+
self.loggingEnabled = loggingEnabled
272+
self.logger = logger ?? Logger(
273+
subsystem: "ObservableStore",
274+
category: "Store"
275+
)
276+
277+
self.cancelFx = self.fx
278+
.sink(receiveValue: { [weak self] action in
279+
self?.send(action)
280+
})
212281
}
213282

214283
/// Initialize with a closure that receives environment.
215284
/// Useful for initializing model properties from environment, and for
216285
/// kicking off actions once at store creation.
217286
public convenience init(
218287
create: (Model.Environment) -> Update<Model>,
219-
environment: Model.Environment
288+
environment: Model.Environment,
289+
loggingEnabled: Bool = false,
290+
logger: Logger? = nil
220291
) {
221292
let update = create(environment)
222-
self.init(state: update.state, environment: environment)
293+
self.init(
294+
state: update.state,
295+
environment: environment,
296+
loggingEnabled: loggingEnabled,
297+
logger: logger
298+
)
223299
self.subscribe(to: update.fx)
224300
}
225301

@@ -229,69 +305,40 @@ where Model: ModelProtocol
229305
public convenience init(
230306
state: Model,
231307
action: Model.Action,
232-
environment: Model.Environment
308+
environment: Model.Environment,
309+
loggingEnabled: Bool = false,
310+
logger: Logger? = nil
233311
) {
234-
self.init(state: state, environment: environment)
312+
self.init(
313+
state: state,
314+
environment: environment,
315+
loggingEnabled: loggingEnabled,
316+
logger: logger
317+
)
235318
self.send(action)
236319
}
237320

238-
/// Subscribe to a publisher of actions, piping them through to
239-
/// the store.
240-
///
241-
/// Holds on to the cancellable until publisher completes.
242-
/// When publisher completes, removes cancellable.
321+
/// Subscribe to a publisher of actions, send the actions it publishes
322+
/// to the store.
243323
public func subscribe(to fx: Fx<Model.Action>) {
244-
// Create a UUID for the cancellable.
245-
// Store cancellable in dictionary by UUID.
246-
// Remove cancellable from dictionary upon effect completion.
247-
// This retains the effect pipeline for as long as it takes to complete
248-
// the effect, and then removes it, so we don't have a cancellables
249-
// memory leak.
250-
let id = UUID()
251-
252-
// Receive Fx on main thread. This does two important things:
253-
//
254-
// First, SwiftUI requires that any state mutations that would change
255-
// views happen on the main thread. Receiving on main ensures that
256-
// all fx-driven state transitions happen on main, even if the
257-
// publisher is off-main-thread.
258-
//
259-
// Second, if we didn't schedule receive on main, it would be possible
260-
// for publishers to complete immediately, causing receiveCompletion
261-
// to attempt to remove the publisher from `cancellables` before
262-
// it is added. By scheduling to receive publisher on main,
263-
// we force publisher to complete on next tick, ensuring that it
264-
// is always first added, then removed from `cancellables`.
265-
let cancellable = fx
266-
.receive(
267-
on: DispatchQueue.main,
268-
options: .init(qos: .default)
269-
)
270-
.sink(
271-
receiveCompletion: { [weak self] _ in
272-
self?.cancellables.removeValue(forKey: id)
273-
},
274-
receiveValue: { [weak self] action in
275-
self?.send(action)
276-
}
277-
)
278-
self.cancellables[id] = cancellable
324+
self._fxBatches.send(fx)
279325
}
280326

281327
/// Send an action to the store to update state and generate effects.
282328
/// Any effects generated are fed back into the store.
283329
///
284330
/// Note: SwiftUI requires that all UI changes happen on main thread.
285-
/// We run effects as-given, without forcing them on to main thread.
286-
/// This means that main-thread effects will be run immediately, enabling
287-
/// you to drive things like withAnimation via actions.
288-
/// However it also means that publishers which run off-main-thread MUST
289-
/// make sure that they join the main thread (e.g. with
290-
/// `.receive(on: DispatchQueue.main)`).
331+
/// `send(_:)` is run *synchronously*. It is up to you to guarantee it is
332+
/// run on main thread when SwiftUI is being used.
291333
public func send(_ action: Model.Action) {
292-
/// Broadcast action to any outside subscribers
293-
self._actions.send(action)
294-
// Generate next state and effect
334+
if loggingEnabled {
335+
logger.log("Action: \(String(describing: action))")
336+
}
337+
338+
// Dispatch action before state change
339+
_actions.send(action)
340+
341+
// Create next state update
295342
let next = Model.update(
296343
state: self.state,
297344
action: action,
@@ -319,8 +366,12 @@ where Model: ModelProtocol
319366
self.state = next.state
320367
}
321368
}
322-
// Run effect
369+
370+
// Run effects
323371
self.subscribe(to: next.fx)
372+
373+
// Dispatch update after state change
374+
self._updates.send(next)
324375
}
325376
}
326377

Tests/ObservableStoreTests/BindingTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ final class BindingTests: XCTestCase {
8585

8686
view.text = "Foo"
8787
view.text = "Bar"
88-
88+
8989
XCTAssertEqual(
9090
store.state.text,
9191
"Bar"

0 commit comments

Comments
 (0)