Skip to content

Commit c7af576

Browse files
Revert "Make update a protocol (#41)" (#42)
1 parent c85e83d commit c7af576

5 files changed

Lines changed: 188 additions & 163 deletions

File tree

Sources/ObservableStore/ObservableStore.swift

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

1110
/// Fx is a publisher that publishes actions and never fails.
1211
public typealias Fx<Action> = AnyPublisher<Action, Never>
@@ -17,15 +16,11 @@ public protocol ModelProtocol: Equatable {
1716
associatedtype Action
1817
associatedtype Environment
1918

20-
associatedtype UpdateType: UpdateProtocol where
21-
UpdateType.Model == Self,
22-
UpdateType.Action == Self.Action
23-
2419
static func update(
2520
state: Self,
2621
action: Action,
2722
environment: Environment
28-
) -> UpdateType
23+
) -> Update<Self>
2924
}
3025

3126
extension ModelProtocol {
@@ -40,16 +35,16 @@ extension ModelProtocol {
4035
state: Self,
4136
actions: [Action],
4237
environment: Environment
43-
) -> UpdateType {
38+
) -> Update<Self> {
4439
actions.reduce(
45-
UpdateType(state: state),
40+
Update(state: state),
4641
{ result, action in
4742
let next = update(
4843
state: result.state,
4944
action: action,
5045
environment: environment
5146
)
52-
return UpdateType(
47+
return Update(
5348
state: next.state,
5449
fx: result.fx.merge(with: next.fx).eraseToAnyPublisher(),
5550
transaction: next.transaction
@@ -79,66 +74,70 @@ extension ModelProtocol {
7974
state: Self,
8075
action viewAction: ViewModel.Action,
8176
environment: ViewModel.Environment
82-
) -> UpdateType {
77+
) -> Update<Self> {
8378
// If getter returns nil (as in case of a list item that no longer
8479
// exists), do nothing.
8580
guard let inner = get(state) else {
86-
return UpdateType(state: state)
81+
return Update(state: state)
8782
}
8883
let next = ViewModel.update(
8984
state: inner,
9085
action: viewAction,
9186
environment: environment
9287
)
93-
return UpdateType(
88+
return Update(
9489
state: set(state, next.state),
9590
fx: next.fx.map(tag).eraseToAnyPublisher(),
9691
transaction: next.transaction
9792
)
9893
}
9994
}
10095

101-
/// `UpdateProtocol` represents a state change, together with an `Fx` publisher,
96+
/// Update represents a state change, together with an `Fx` publisher,
10297
/// and an optional `Transaction`.
103-
public protocol UpdateProtocol {
104-
associatedtype Model
105-
associatedtype Action
106-
107-
init(
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(
108113
state: Model,
109-
fx: Fx<Action>,
114+
fx: Fx<Model.Action>,
110115
transaction: Transaction?
111-
)
112-
113-
var state: Model { get set }
114-
var fx: Fx<Action> { get set }
115-
var transaction: Transaction? { get set }
116-
}
116+
) {
117+
self.state = state
118+
self.fx = fx
119+
self.transaction = transaction
120+
}
117121

118-
extension UpdateProtocol {
119122
public init(state: Model, animation: Animation? = nil) {
120-
self.init(
121-
state: state,
122-
fx: Empty(completeImmediately: true).eraseToAnyPublisher(),
123-
transaction: Transaction(animation: animation)
124-
)
123+
self.state = state
124+
self.fx = Empty(completeImmediately: true).eraseToAnyPublisher()
125+
self.transaction = Transaction(animation: animation)
125126
}
126-
127+
127128
public init(
128129
state: Model,
129-
fx: Fx<Action>,
130+
fx: Fx<Model.Action>,
130131
animation: Animation? = nil
131132
) {
132-
self.init(
133-
state: state,
134-
fx: fx,
135-
transaction: Transaction(animation: animation)
136-
)
133+
self.state = state
134+
self.fx = fx
135+
self.transaction = Transaction(animation: animation)
137136
}
138-
137+
139138
/// Merge existing fx together with new fx.
140139
/// - Returns a new `Update`
141-
public func mergeFx(_ fx: Fx<Action>) -> Self {
140+
public func mergeFx(_ fx: Fx<Model.Action>) -> Update<Model> {
142141
var this = self
143142
this.fx = self.fx.merge(with: fx).eraseToAnyPublisher()
144143
return this
@@ -154,34 +153,6 @@ extension UpdateProtocol {
154153
}
155154
}
156155

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-
185156
/// A store is any type that can
186157
/// - get a state
187158
/// - send actions
@@ -204,43 +175,17 @@ public protocol StoreProtocol {
204175
public final class Store<Model>: ObservableObject, StoreProtocol
205176
where Model: ModelProtocol
206177
{
207-
private var cancelTransactions: AnyCancellable?
208-
209-
/// Cancellable for fx subscription.
210-
private var cancelFx: AnyCancellable?
211-
178+
/// Stores cancellables by ID
179+
private(set) var cancellables: [UUID: AnyCancellable] = [:]
212180
/// Private for all actions sent to the store.
213-
private var _actions = PassthroughSubject<Model.Action, Never>()
214-
181+
private var _actions: PassthroughSubject<Model.Action, Never>
215182
/// Publisher for all actions sent to the store.
216183
public var actions: AnyPublisher<Model.Action, Never> {
217184
_actions.eraseToAnyPublisher()
218185
}
219-
220-
/// Source publisher for batches of fx modeled as publishers.
221-
private var _fxBatches = PassthroughSubject<Fx<Model.Action>, Never>()
222-
223-
/// `fx` represents a flat stream of actions from all fx publishers.
224-
private var fx: AnyPublisher<Model.Action, Never> {
225-
_fxBatches
226-
.flatMap({ publisher in publisher })
227-
.receive(on: DispatchQueue.main)
228-
.eraseToAnyPublisher()
229-
}
230-
231-
/// Publisher for updates performed on state
232-
private var _updates = PassthroughSubject<Model.UpdateType, Never>()
233-
234-
/// Publisher for updates performed on state.
235-
/// `updates` is guaranteed to fire after the state has changed.
236-
public var updates: AnyPublisher<Model.UpdateType, Never> {
237-
_updates.eraseToAnyPublisher()
238-
}
239-
240186
/// Current state.
241187
/// All writes to state happen through actions sent to `Store.send`.
242188
@Published public private(set) var state: Model
243-
244189
/// Environment, which typically holds references to outside information,
245190
/// such as API methods.
246191
///
@@ -257,47 +202,24 @@ where Model: ModelProtocol
257202
/// app is stopped.
258203
public var environment: Model.Environment
259204

260-
/// Logger to log actions sent to store.
261-
private var logger: Logger
262-
/// Should log?
263-
var loggingEnabled: Bool
264-
265205
public init(
266206
state: Model,
267-
environment: Model.Environment,
268-
loggingEnabled: Bool = false,
269-
logger: Logger? = nil
207+
environment: Model.Environment
270208
) {
271209
self.state = state
272210
self.environment = environment
273-
self.loggingEnabled = loggingEnabled
274-
self.logger = logger ?? Logger(
275-
subsystem: "ObservableStore",
276-
category: "Store"
277-
)
278-
279-
self.cancelFx = self.fx
280-
.sink(receiveValue: { [weak self] action in
281-
self?.send(action)
282-
})
211+
self._actions = PassthroughSubject<Model.Action, Never>()
283212
}
284213

285214
/// Initialize with a closure that receives environment.
286215
/// Useful for initializing model properties from environment, and for
287216
/// kicking off actions once at store creation.
288217
public convenience init(
289218
create: (Model.Environment) -> Update<Model>,
290-
environment: Model.Environment,
291-
loggingEnabled: Bool = false,
292-
logger: Logger? = nil
219+
environment: Model.Environment
293220
) {
294221
let update = create(environment)
295-
self.init(
296-
state: update.state,
297-
environment: environment,
298-
loggingEnabled: loggingEnabled,
299-
logger: logger
300-
)
222+
self.init(state: update.state, environment: environment)
301223
self.subscribe(to: update.fx)
302224
}
303225

@@ -307,40 +229,69 @@ where Model: ModelProtocol
307229
public convenience init(
308230
state: Model,
309231
action: Model.Action,
310-
environment: Model.Environment,
311-
loggingEnabled: Bool = false,
312-
logger: Logger? = nil
232+
environment: Model.Environment
313233
) {
314-
self.init(
315-
state: state,
316-
environment: environment,
317-
loggingEnabled: loggingEnabled,
318-
logger: logger
319-
)
234+
self.init(state: state, environment: environment)
320235
self.send(action)
321236
}
322237

323-
/// Subscribe to a publisher of actions, send the actions it publishes
324-
/// to the store.
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.
325243
public func subscribe(to fx: Fx<Model.Action>) {
326-
self._fxBatches.send(fx)
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
327279
}
328280

329281
/// Send an action to the store to update state and generate effects.
330282
/// Any effects generated are fed back into the store.
331283
///
332284
/// Note: SwiftUI requires that all UI changes happen on main thread.
333-
/// `send(_:)` is run *synchronously*. It is up to you to guarantee it is
334-
/// run on main thread when SwiftUI is being used.
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)`).
335291
public func send(_ action: Model.Action) {
336-
if loggingEnabled {
337-
logger.log("Action: \(String(describing: action))")
338-
}
339-
340-
// Dispatch action before state change
341-
_actions.send(action)
342-
343-
// Create next state update
292+
/// Broadcast action to any outside subscribers
293+
self._actions.send(action)
294+
// Generate next state and effect
344295
let next = Model.update(
345296
state: self.state,
346297
action: action,
@@ -368,12 +319,8 @@ where Model: ModelProtocol
368319
self.state = next.state
369320
}
370321
}
371-
372-
// Run effects
322+
// Run effect
373323
self.subscribe(to: next.fx)
374-
375-
// Dispatch update after state change
376-
self._updates.send(next)
377324
}
378325
}
379326

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)