Skip to content

Commit e5e10d8

Browse files
authored
Update .cancellable implementation to fix #946 (#949)
* Subscribe to upstream publisher instead of PassthroughSubject * Fixed operator order * Replaced RunLoop.immediate with RunLoop.test
1 parent 313dd21 commit e5e10d8

File tree

3 files changed

+29
-12
lines changed

3 files changed

+29
-12
lines changed

Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class VoiceMemosTests: XCTestCase {
113113
environment.audioRecorder.startRecording = { _ in
114114
audioRecorderSubject.eraseToEffect()
115115
}
116-
environment.mainRunLoop = .immediate
116+
environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()
117117
environment.temporaryDirectory = { .init(fileURLWithPath: "/tmp") }
118118
environment.uuid = { UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")! }
119119

@@ -124,6 +124,7 @@ class VoiceMemosTests: XCTestCase {
124124
)
125125

126126
store.send(.recordButtonTapped)
127+
self.mainRunLoop.advance(by: 0.5)
127128
store.receive(.recordPermissionResponse(true)) {
128129
$0.audioRecorderPermission = .allowed
129130
$0.currentRecording = .init(
@@ -133,6 +134,7 @@ class VoiceMemosTests: XCTestCase {
133134
)
134135
}
135136
audioRecorderSubject.send(completion: .failure(.couldntActivateAudioSession))
137+
self.mainRunLoop.advance(by: 0.5)
136138
store.receive(.audioRecorder(.failure(.couldntActivateAudioSession))) {
137139
$0.alert = .init(title: .init("Voice memo recording failed."))
138140
$0.currentRecording = nil
@@ -189,7 +191,7 @@ class VoiceMemosTests: XCTestCase {
189191
func testPlayMemoFailure() {
190192
var environment = VoiceMemosEnvironment.failing
191193
environment.audioPlayer.play = { _ in Effect(error: .decodeErrorDidOccur) }
192-
environment.mainRunLoop = .immediate
194+
environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()
193195

194196
let url = URL(string: "https://www.pointfree.co/functions")!
195197
let store = TestStore(
@@ -278,7 +280,7 @@ class VoiceMemosTests: XCTestCase {
278280
var environment = VoiceMemosEnvironment.failing
279281
environment.audioPlayer.play = { _ in .none }
280282
environment.audioPlayer.stop = { .none }
281-
environment.mainRunLoop = .immediate
283+
environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()
282284

283285
let store = TestStore(
284286
initialState: VoiceMemosState(

Sources/ComposableArchitecture/Effects/Cancellation.swift

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,16 @@ extension Effect {
2929
/// canceled before starting this new one.
3030
/// - Returns: A new effect that is capable of being canceled by an identifier.
3131
public func cancellable(id: AnyHashable, cancelInFlight: Bool = false) -> Effect {
32-
let effect = Deferred { () -> Publishers.HandleEvents<PassthroughSubject<Output, Failure>> in
32+
let effect = Deferred { () -> Publishers.HandleEvents<Publishers.PrefixUntilOutput<Self, PassthroughSubject<Void, Never>>> in
3333
cancellablesLock.lock()
3434
defer { cancellablesLock.unlock() }
3535

36-
let subject = PassthroughSubject<Output, Failure>()
37-
let cancellable = self.subscribe(subject)
36+
let cancellationSubject = PassthroughSubject<Void, Never>()
3837

3938
var cancellationCancellable: AnyCancellable!
4039
cancellationCancellable = AnyCancellable {
4140
cancellablesLock.sync {
42-
subject.send(completion: .finished)
43-
cancellable.cancel()
41+
cancellationSubject.send(())
4442
cancellationCancellables[id]?.remove(cancellationCancellable)
4543
if cancellationCancellables[id]?.isEmpty == .some(true) {
4644
cancellationCancellables[id] = nil
@@ -52,10 +50,11 @@ extension Effect {
5250
cancellationCancellable
5351
)
5452

55-
return subject.handleEvents(
56-
receiveCompletion: { _ in cancellationCancellable.cancel() },
57-
receiveCancel: cancellationCancellable.cancel
58-
)
53+
return self.prefix(untilOutputFrom: cancellationSubject)
54+
.handleEvents(
55+
receiveCompletion: { _ in cancellationCancellable.cancel() },
56+
receiveCancel: cancellationCancellable.cancel
57+
)
5958
}
6059
.eraseToEffect()
6160

Tests/ComposableArchitectureTests/EffectCancellationTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,20 @@ final class EffectCancellationTests: XCTestCase {
286286
scheduler.advance(by: 1)
287287
XCTAssertNoDifference(expectedOutput, [])
288288
}
289+
290+
func testNestedMergeCancellation() {
291+
let effect = Effect<Int, Never>.merge(
292+
(1...2).publisher
293+
.eraseToEffect()
294+
.cancellable(id: 1)
295+
)
296+
.cancellable(id: 2)
297+
298+
var output: [Int] = []
299+
effect
300+
.sink { output.append($0) }
301+
.store(in: &cancellables)
302+
303+
XCTAssertEqual(output, [1, 2])
304+
}
289305
}

0 commit comments

Comments
 (0)