Skip to content

Commit 6547bed

Browse files
committed
Prevented reference retention in withPrevious(…)
1 parent 72f0f1c commit 6547bed

2 files changed

Lines changed: 66 additions & 7 deletions

File tree

Sources/CombineExtensions/Operators/WithPrevious.swift

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import Combine
22

3-
// Inspired by https://stackoverflow.com/a/67133582/670119
4-
53
extension Publisher {
64

75
/// Transforms elements from the upstream publisher into a tuple containing the current and
@@ -19,9 +17,19 @@ extension Publisher {
1917
///
2018
/// - Returns: A publisher that emits a tuple of the previous and current elements from the
2119
/// upstream publisher.
20+
///
21+
/// - Note: Until version 0.3.0 of the library, this was implemented using `.scan` (see
22+
/// [this post](https://stackoverflow.com/a/67133582/670119)). However, that approach
23+
/// strongly retained previous values and could keep them in memory longer than expected
24+
/// when `Output` was a reference type. This version avoids that by using a local variable
25+
/// inside `map`, which does not retain previous values beyond each emission.
2226
public func withPrevious() -> AnyPublisher<(previous: Output?, current: Output), Failure> {
23-
self.scan(Optional<(Output?, Output)>.none) { ($0?.1, $1) }
24-
.compactMap { $0 }
27+
var previous: Output?
28+
return self
29+
.map { current in
30+
defer { previous = current }
31+
return (previous: previous, current: current)
32+
}
2533
.eraseToAnyPublisher()
2634
}
2735

@@ -42,10 +50,21 @@ extension Publisher {
4250
/// emission.
4351
/// - Returns: A publisher that emits a tuple of the previous and current elements from the
4452
/// upstream publisher.
53+
///
54+
/// - Note: Until version 0.3.0 of the library, this was implemented using `.scan` (see
55+
/// [this post](https://stackoverflow.com/a/67133582/670119)). However, that approach
56+
/// strongly retained previous values and could keep them in memory longer than expected
57+
/// when `Output` was a reference type. This version avoids that by using a local variable
58+
/// inside `map`, which does not retain previous values beyond each emission.
4559
public func withPrevious(
4660
initialValue: Output
4761
) -> AnyPublisher<(previous: Output, current: Output), Failure> {
48-
self.scan((initialValue, initialValue)) { ($0.1, $1) }
62+
var previous = initialValue
63+
return self
64+
.map { current in
65+
defer { previous = current }
66+
return (previous: previous, current: current)
67+
}
4968
.eraseToAnyPublisher()
5069
}
5170

Tests/CombineExtensionsTests/Operators/WithPreviousTests.swift

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ class WithPreviousTests: XCTestCase {
1313

1414
subject
1515
.withPrevious()
16-
.sink { _ in completed = true } receiveValue: { pair in
16+
.sink { _ in
17+
completed = true
18+
} receiveValue: { pair in
1719
results.append([pair.previous, pair.current])
1820
}
1921
.store(in: &cancellables)
@@ -41,7 +43,9 @@ class WithPreviousTests: XCTestCase {
4143

4244
subject
4345
.withPrevious(initialValue: 0)
44-
.sink { _ in completed = true } receiveValue: { pair in
46+
.sink { _ in
47+
completed = true
48+
} receiveValue: { pair in
4549
results.append([pair.previous, pair.current])
4650
}
4751
.store(in: &cancellables)
@@ -96,4 +100,40 @@ class WithPreviousTests: XCTestCase {
96100
XCTAssertTrue(completed)
97101
}
98102

103+
func testWithPrevious_doesNotRetainObjects() {
104+
var cancellables = Set<AnyCancellable>()
105+
var deallocatedIDs = Set<Int>()
106+
107+
let subject = PassthroughSubject<TestObject, Never>()
108+
109+
subject
110+
.withPrevious()
111+
.sink { _ in }
112+
.store(in: &cancellables)
113+
114+
for id in 0...2 {
115+
let object = TestObject(id: id) { deallocatedIDs.insert(id) }
116+
subject.send(object)
117+
}
118+
119+
XCTAssertEqual(deallocatedIDs, [0, 1])
120+
121+
subject.send(completion: .finished)
122+
XCTAssertEqual(deallocatedIDs, [0, 1, 2])
123+
}
124+
125+
}
126+
127+
private final class TestObject {
128+
let id: Int
129+
let onDeinit: () -> Void
130+
131+
init(id: Int, onDeinit: @escaping () -> Void) {
132+
self.id = id
133+
self.onDeinit = onDeinit
134+
}
135+
136+
deinit {
137+
self.onDeinit()
138+
}
99139
}

0 commit comments

Comments
 (0)