Skip to content

Commit 41f3356

Browse files
Go back to manually managed fx lifetimes (#45)
1 parent 371a014 commit 41f3356

File tree

2 files changed

+123
-22
lines changed

2 files changed

+123
-22
lines changed

Sources/ObservableStore/ObservableStore.swift

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,8 @@ public protocol StoreProtocol {
204204
public final class Store<Model>: ObservableObject, StoreProtocol
205205
where Model: ModelProtocol
206206
{
207-
/// Cancellable for fx subscription.
208-
private var cancelFx: AnyCancellable?
207+
/// Stores cancellables by ID
208+
private(set) var cancellables: [UUID: AnyCancellable] = [:]
209209

210210
/// Private for all actions sent to the store.
211211
private var _actions = PassthroughSubject<Model.Action, Never>()
@@ -215,17 +215,6 @@ where Model: ModelProtocol
215215
_actions.eraseToAnyPublisher()
216216
}
217217

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-
229218
/// Publisher for updates performed on state
230219
private var _updates = PassthroughSubject<Model.UpdateType, Never>()
231220

@@ -273,11 +262,6 @@ where Model: ModelProtocol
273262
subsystem: "ObservableStore",
274263
category: "Store"
275264
)
276-
277-
self.cancelFx = self.fx
278-
.sink(receiveValue: { [weak self] action in
279-
self?.send(action)
280-
})
281265
}
282266

283267
/// Initialize with a closure that receives environment.
@@ -318,12 +302,49 @@ where Model: ModelProtocol
318302
self.send(action)
319303
}
320304

321-
/// Subscribe to a publisher of actions, send the actions it publishes
322-
/// to the store.
305+
/// Subscribe to a publisher of actions, piping them through to
306+
/// the store.
307+
///
308+
/// Holds on to the cancellable until publisher completes.
309+
/// When publisher completes, removes cancellable.
323310
public func subscribe(to fx: Fx<Model.Action>) {
324-
self._fxBatches.send(fx)
311+
// Create a UUID for the cancellable.
312+
// Store cancellable in dictionary by UUID.
313+
// Remove cancellable from dictionary upon effect completion.
314+
// This retains the effect pipeline for as long as it takes to complete
315+
// the effect, and then removes it, so we don't have a cancellables
316+
// memory leak.
317+
let id = UUID()
318+
319+
// Receive Fx on main thread. This does two important things:
320+
//
321+
// First, SwiftUI requires that any state mutations that would change
322+
// views happen on the main thread. Receiving on main ensures that
323+
// all fx-driven state transitions happen on main, even if the
324+
// publisher is off-main-thread.
325+
//
326+
// Second, if we didn't schedule receive on main, it would be possible
327+
// for publishers to complete immediately, causing receiveCompletion
328+
// to attempt to remove the publisher from `cancellables` before
329+
// it is added. By scheduling to receive publisher on main,
330+
// we force publisher to complete on next tick, ensuring that it
331+
// is always first added, then removed from `cancellables`.
332+
let cancellable = fx
333+
.receive(
334+
on: DispatchQueue.main,
335+
options: .init(qos: .default)
336+
)
337+
.sink(
338+
receiveCompletion: { [weak self] _ in
339+
self?.cancellables.removeValue(forKey: id)
340+
},
341+
receiveValue: { [weak self] action in
342+
self?.send(action)
343+
}
344+
)
345+
self.cancellables[id] = cancellable
325346
}
326-
347+
327348
/// Send an action to the store to update state and generate effects.
328349
/// Any effects generated are fed back into the store.
329350
///

Tests/ObservableStoreTests/ObservableStoreTests.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,86 @@ final class ObservableStoreTests: XCTestCase {
9797
XCTAssertEqual(store.state.count, 1, "state is advanced")
9898
}
9999

100+
/// Tests that the immediately-completing empty Fx used as the default for
101+
/// updates get removed from the cancellables array.
102+
///
103+
/// Failure to remove immediately-completing fx would cause a memory leak.
104+
func testEmptyFxRemovedOnComplete() {
105+
let store = Store(
106+
state: AppModel(),
107+
environment: AppModel.Environment()
108+
)
109+
store.send(.increment)
110+
store.send(.increment)
111+
store.send(.increment)
112+
let expectation = XCTestExpectation(
113+
description: "cancellable removed when publisher completes"
114+
)
115+
DispatchQueue.main.async {
116+
XCTAssertEqual(
117+
store.cancellables.count,
118+
0,
119+
"cancellables removed when publisher completes"
120+
)
121+
expectation.fulfill()
122+
}
123+
wait(for: [expectation], timeout: 0.1)
124+
}
125+
126+
/// Tests that immediately-completing Fx get removed from the cancellables.
127+
///
128+
/// array. Failure to remove immediately-completing fx would cause a
129+
/// memory leak.
130+
///
131+
/// When you don't specify fx for an update, we default to
132+
/// an immediately-completing `Empty` publisher, so this test is
133+
/// technically the same as the one above. The difference is that it
134+
/// does not rely on an implementation detail of `Update` but instead
135+
/// tests this behavior directly, in case the implementation were to
136+
/// change somehow.
137+
func testEmptyFxThatCompleteImmiedatelyRemovedOnComplete() {
138+
let store = Store(
139+
state: AppModel(),
140+
environment: AppModel.Environment()
141+
)
142+
store.send(.createEmptyFxThatCompletesImmediately)
143+
store.send(.createEmptyFxThatCompletesImmediately)
144+
store.send(.createEmptyFxThatCompletesImmediately)
145+
let expectation = XCTestExpectation(
146+
description: "cancellable removed when publisher completes"
147+
)
148+
DispatchQueue.main.async {
149+
XCTAssertEqual(
150+
store.cancellables.count,
151+
0,
152+
"cancellables removed when publisher completes"
153+
)
154+
expectation.fulfill()
155+
}
156+
wait(for: [expectation], timeout: 0.1)
157+
}
158+
159+
func testAsyncFxRemovedOnComplete() {
160+
let store = Store(
161+
state: AppModel(),
162+
environment: AppModel.Environment()
163+
)
164+
store.send(.delayIncrement(0.1))
165+
store.send(.delayIncrement(0.2))
166+
let expectation = XCTestExpectation(
167+
description: "cancellable removed when publisher completes"
168+
)
169+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
170+
XCTAssertEqual(
171+
store.cancellables.count,
172+
0,
173+
"cancellables removed when publisher completes"
174+
)
175+
expectation.fulfill()
176+
}
177+
wait(for: [expectation], timeout: 0.5)
178+
}
179+
100180
func testPublishedPropertyFires() throws {
101181
let store = Store(
102182
state: AppModel(),

0 commit comments

Comments
 (0)