Skip to content

Commit 266fb53

Browse files
authored
Explicit effect disposables management (#39)
* fix(Store): Effect disposables do not deinitialize * Merge branch 'effect-disposables-deinitialization' into main * fix: ActionDisposable leaks * fix: PR notes * fix: PR notes
1 parent 051f83d commit 266fb53

File tree

3 files changed

+109
-36
lines changed

3 files changed

+109
-36
lines changed

Sources/ComposableArchitecture/Store.swift

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import ReactiveSwift
77
/// You will typically construct a single one of these at the root of your application, and then use
88
/// the `scope` method to derive more focused stores that can be passed to subviews.
99
public final class Store<State, Action> {
10-
@MutableProperty private(set) var state: State
10+
@MutableProperty
11+
private(set) var state: State
12+
1113
private var isSending = false
1214
private let reducer: (inout State, Action) -> Effect<Action, Never>
1315
private var synchronousActionsToSend: [Action] = []
1416
private var bufferedActions: [Action] = []
15-
17+
internal var effectDisposables: [UUID: Disposable] = [:]
18+
internal var parentDisposable: Disposable?
19+
1620
/// Initializes a store from an initial state, a reducer, and an environment.
1721
///
1822
/// - Parameters:
@@ -29,7 +33,7 @@ public final class Store<State, Action> {
2933
reducer: { reducer.run(&$0, $1, environment) }
3034
)
3135
}
32-
36+
3337
/// Scopes the store to one that exposes local state and actions.
3438
///
3539
/// This can be useful for deriving new stores to hand to child views in an application. For
@@ -170,11 +174,12 @@ public final class Store<State, Action> {
170174
return .none
171175
}
172176
)
173-
self.$state.producer
174-
.startWithValues { [weak localStore] newValue in localStore?.state = toLocalState(newValue) }
177+
localStore.parentDisposable = self.$state.producer.startWithValues { [weak localStore] state in
178+
localStore?.state = toLocalState(state)
179+
}
175180
return localStore
176181
}
177-
182+
178183
/// Scopes the store to one that exposes local state.
179184
///
180185
/// - Parameter toLocalState: A function that transforms `State` into `LocalState`.
@@ -184,7 +189,7 @@ public final class Store<State, Action> {
184189
) -> Store<LocalState, Action> {
185190
self.scope(state: toLocalState, action: { $0 })
186191
}
187-
192+
188193
/// Scopes the store to a producer of stores of more local state and local actions.
189194
///
190195
/// - Parameters:
@@ -196,14 +201,14 @@ public final class Store<State, Action> {
196201
state toLocalState: @escaping (Effect<State, Never>) -> Effect<LocalState, Never>,
197202
action fromLocalAction: @escaping (LocalAction) -> Action
198203
) -> Effect<Store<LocalState, LocalAction>, Never> {
199-
204+
200205
func extractLocalState(_ state: State) -> LocalState? {
201206
var localState: LocalState?
202207
_ = toLocalState(Effect(value: state))
203208
.startWithValues { localState = $0 }
204209
return localState
205210
}
206-
211+
207212
return toLocalState(self.$state.producer)
208213
.map { localState in
209214
let localStore = Store<LocalState, LocalAction>(
@@ -212,17 +217,17 @@ public final class Store<State, Action> {
212217
self.send(fromLocalAction(localAction))
213218
localState = extractLocalState(self.state) ?? localState
214219
return .none
215-
})
216-
217-
self.$state.producer
218-
.startWithValues { [weak localStore] state in
219-
guard let localStore = localStore else { return }
220-
localStore.state = extractLocalState(state) ?? localStore.state
221220
}
221+
)
222+
223+
localStore.parentDisposable = self.$state.producer.startWithValues { [weak localStore] state in
224+
guard let localStore = localStore else { return }
225+
localStore.state = extractLocalState(state) ?? localStore.state
226+
}
222227
return localStore
223228
}
224229
}
225-
230+
226231
/// Scopes the store to a producer of stores of more local state and local actions.
227232
///
228233
/// - Parameter toLocalState: A function that transforms a producer of `State` into a producer
@@ -234,66 +239,95 @@ public final class Store<State, Action> {
234239
) -> Effect<Store<LocalState, Action>, Never> {
235240
self.producerScope(state: toLocalState, action: { $0 })
236241
}
237-
242+
238243
func send(_ action: Action) {
239244
if !self.isSending {
240245
self.synchronousActionsToSend.append(action)
241246
} else {
242247
self.bufferedActions.append(action)
243248
return
244249
}
245-
250+
246251
while !self.synchronousActionsToSend.isEmpty || !self.bufferedActions.isEmpty {
247252
let action =
248253
!self.synchronousActionsToSend.isEmpty
249254
? self.synchronousActionsToSend.removeFirst()
250255
: self.bufferedActions.removeFirst()
251-
256+
252257
self.isSending = true
253258
let effect = self.reducer(&self.state, action)
254259
self.isSending = false
255-
260+
261+
var didComplete = false
262+
let effectID = UUID()
263+
256264
var isProcessingEffects = true
257-
effect.startWithValues { [weak self] action in
258-
if isProcessingEffects {
259-
self?.synchronousActionsToSend.append(action)
260-
} else {
261-
self?.send(action)
265+
266+
let observer = Signal<Action, Never>.Observer(
267+
value: { [weak self] action in
268+
if isProcessingEffects {
269+
self?.synchronousActionsToSend.append(action)
270+
} else {
271+
self?.send(action)
272+
}
273+
},
274+
failed: .none,
275+
completed: { [weak self] in
276+
didComplete = true
277+
self?.effectDisposables.removeValue(forKey: effectID)?.dispose()
278+
},
279+
interrupted: { [weak self] in
280+
didComplete = true
281+
self?.effectDisposables.removeValue(forKey: effectID)?.dispose()
262282
}
263-
}
283+
)
284+
let effectDisposable = effect.start(observer)
264285
isProcessingEffects = false
286+
287+
if !didComplete {
288+
self.effectDisposables[effectID] = effectDisposable
289+
} else {
290+
effectDisposable.dispose()
291+
}
265292
}
266293
}
267-
294+
268295
/// Returns a "stateless" store by erasing state to `Void`.
269296
public var stateless: Store<Void, Action> {
270297
self.scope(state: { _ in () })
271298
}
272-
299+
273300
/// Returns an "actionless" store by erasing action to `Never`.
274301
public var actionless: Store<State, Never> {
275302
func absurd<A>(_ never: Never) -> A {}
276303
return self.scope(state: { $0 }, action: absurd)
277304
}
278-
305+
279306
private init(
280307
initialState: State,
281308
reducer: @escaping (inout State, Action) -> Effect<Action, Never>
282309
) {
283310
self.reducer = reducer
284311
self.state = initialState
285312
}
313+
314+
deinit {
315+
self.parentDisposable?.dispose()
316+
self.effectDisposables.keys.forEach { id in
317+
self.effectDisposables.removeValue(forKey: id)?.dispose()
318+
}
319+
}
286320
}
287321

288322
/// A producer of store state.
289323
@dynamicMemberLookup
290324
public struct Produced<Value>: SignalProducerConvertible {
291325
public let producer: Effect<Value, Never>
292-
326+
293327
init(by upstream: Effect<Value, Never>) {
294328
self.producer = upstream
295329
}
296-
330+
297331
/// Returns the resulting producer of a given key path.
298332
public subscript<LocalValue>(
299333
dynamicMember keyPath: KeyPath<Value, LocalValue>
@@ -303,9 +337,9 @@ public struct Produced<Value>: SignalProducerConvertible {
303337
}
304338

305339
@available(
306-
*, deprecated,
307-
message:
308-
"""
340+
*, deprecated,
341+
message:
342+
"""
309343
Consider using `Produced<State>` instead, this typealias is added for backward compatibility and will be removed in the next major release.
310344
"""
311345
)

Sources/ComposableArchitecture/ViewStore.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public final class ViewStore<State, Action> {
6868
"""
6969
)
7070
public var publisher: StoreProducer<State> { produced }
71+
72+
internal var viewDisposable: Disposable?
7173

7274
/// Initializes a view store from a store.
7375
///
@@ -83,8 +85,8 @@ public final class ViewStore<State, Action> {
8385
self.produced = Produced(by: producer)
8486
self.state = store.state
8587
self._send = store.send
86-
producer.startWithValues { [weak self] in
87-
self?.state = $0
88+
self.viewDisposable = producer.startWithValues { [weak self] state in
89+
self?.state = state
8890
}
8991
}
9092

@@ -257,6 +259,10 @@ public final class ViewStore<State, Action> {
257259
self.binding(send: { _ in action })
258260
}
259261
#endif
262+
263+
deinit {
264+
viewDisposable?.dispose()
265+
}
260266
}
261267

262268
extension ViewStore where State: Equatable {

Tests/ComposableArchitectureTests/StoreTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ import XCTest
44
@testable import ComposableArchitecture
55

66
final class StoreTests: XCTestCase {
7+
8+
func testEffectDisposablesDeinitialization() {
9+
enum Action {
10+
case triggerDelay
11+
case delayDidComplete
12+
}
13+
let delayedReducer = Reducer<Void, Action, DateScheduler> { _, action, mainQueue in
14+
switch action {
15+
case .triggerDelay:
16+
return Effect(value: .delayDidComplete).delay(1, on: mainQueue)
17+
18+
case .delayDidComplete:
19+
return .none
20+
}
21+
}
22+
23+
let store = Store(
24+
initialState: (),
25+
reducer: delayedReducer,
26+
environment: QueueScheduler.main
27+
)
28+
29+
store.send(.triggerDelay)
30+
store.send(.triggerDelay)
31+
store.send(.triggerDelay)
32+
store.send(.delayDidComplete)
33+
34+
XCTAssertEqual(store.effectDisposables.count, 3)
35+
36+
XCTWaiter().wait(for: [XCTestExpectation()], timeout: 1.1)
37+
38+
XCTAssertEqual(store.effectDisposables.count, 0)
39+
}
740

841
func testScopedStoreReceivesUpdatesFromParent() {
942
let counterReducer = Reducer<Int, Void, Void> { state, _, _ in

0 commit comments

Comments
 (0)