Skip to content

Commit 2d3acb5

Browse files
committed
feat: add option to control initial emission in publishers
Introduce a new parameter `emitInitial` to control whether the current value is emitted immediately upon subscription to publishers in MemoryBox, HybridBox, and UserDefaultsBox. - Updated all cache box implementations (`PandoraMemoryBox`, `PandoraHybridBox`, `PandoraUserDefaultsBox`) to support the `emitInitial` parameter in their `publisher` methods. - By default, `emitInitial` is set to `true`, maintaining existing behavior. - Introduced comprehensive unit tests to verify the functionality for both `emitInitial: true` and `emitInitial: false` cases. - Updated documentation to reflect the new parameter in all relevant protocols and methods. This change allows users to subscribe to cache updates without receiving the current value, which can be advantageous in scenarios where only future changes are relevant. The default behavior remains unchanged to ensure backward compatibility.
1 parent 8b51f4a commit 2d3acb5

13 files changed

Lines changed: 611 additions & 16 deletions

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
[![Swift](https://img.shields.io/badge/Swift-5.9%2B-orange.svg?style=flat)](https://swift.org)
1313
[![SPM ready](https://img.shields.io/badge/SPM-ready-brightgreen.svg?style=flat-square)](https://swift.org/package-manager/)
14-
[![Coverage](https://img.shields.io/badge/Coverage-97.5%25-brightgreen.svg?style=flat)](#)
14+
[![Coverage](https://img.shields.io/badge/Coverage-97.1%25-brightgreen.svg?style=flat)](#)
1515
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
1616
[![Size](https://img.shields.io/badge/Package_Size-1.5MB-purple.svg?style=flat-square)](#)
1717

@@ -275,14 +275,22 @@ cache.put(
275275
```swift
276276
let cache: PandoraMemoryBox<String, User> = Pandora.Memory.box()
277277

278-
// Observe specific keys
278+
// Observe specific keys (emits current value immediately)
279279
cache.publisher(for: "current_user")
280280
.compactMap { $0 } // Filter out nil values
281281
.sink { user in
282282
print("User updated: \(user.name)")
283283
}
284284
.store(in: &cancellables)
285285

286+
// Observe only future changes (skip current value)
287+
cache.publisher(for: "current_user", emitInitial: false)
288+
.compactMap { $0 }
289+
.sink { user in
290+
print("User changed: \(user.name)")
291+
}
292+
.store(in: &cancellables)
293+
286294
// Chain multiple cache operations
287295
cache.publisher(for: "user_id")
288296
.compactMap { $0 }
@@ -295,6 +303,14 @@ cache.publisher(for: "user_id")
295303
.store(in: &cancellables)
296304
```
297305

306+
#### Publisher Options
307+
308+
All Pandora publishers support an `emitInitial` parameter to control whether the current value is emitted immediately upon subscription:
309+
310+
- `publisher(for: "key")` - Emits current value immediately (default behavior)
311+
- `publisher(for: "key", emitInitial: true)` - Explicitly emit current value
312+
- `publisher(for: "key", emitInitial: false)` - Only emit future changes
313+
298314
### Cache Cleanup
299315

300316
```swift

Sources/Pandora/HybridBox/PandoraHybridBox.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,17 @@ public final class PandoraHybridBox<Key: Hashable & Codable & Sendable, Value: C
4747
self.diskExpiresAfter = diskExpiresAfter
4848
}
4949

50-
public func publisher(for key: Key) -> AnyPublisher<Value?, Never> {
51-
memory.publisher(for: key)
50+
public func publisher(for key: Key, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
51+
if emitInitial {
52+
let currentValue = syncLock.withLock { memory.get(key) }
53+
return Publishers.Merge(
54+
Just(currentValue).eraseToAnyPublisher(),
55+
memory.publisher(for: key, emitInitial: false)
56+
)
57+
.eraseToAnyPublisher()
58+
} else {
59+
return memory.publisher(for: key, emitInitial: false)
60+
}
5261
}
5362

5463
public func get(_ key: Key) async -> Value? {

Sources/Pandora/HybridBox/PandoraHybridBoxProtocol.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ public protocol PandoraHybridBoxProtocol: Sendable {
2222
///
2323
/// - Emits the current value (or nil) on subscription, then updates or removals in real time.
2424
/// - Events are sent immediately for memory changes.
25-
func publisher(for key: Key) -> AnyPublisher<Value?, Never>
25+
/// - Parameters:
26+
/// - key: The cache key to observe.
27+
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
28+
func publisher(for key: Key, emitInitial: Bool) -> AnyPublisher<Value?, Never>
2629

2730
/// Reads a value for the given key, checking memory first, then disk.
2831
///

Sources/Pandora/MemoryBox/PandoraMemoryBox.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public class PandoraMemoryBox<Key: Hashable & Sendable, Value: Sendable>: Pandor
102102
removedSubject?.send(nil)
103103
}
104104

105-
public func publisher(for key: Key) -> AnyPublisher<Value?, Never> {
105+
public func publisher(for key: Key, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
106106
lock.lock()
107107
let subject: CurrentValueSubject<Value?, Never>
108108
if let existing = subjects[key] {
@@ -121,7 +121,11 @@ public class PandoraMemoryBox<Key: Hashable & Sendable, Value: Sendable>: Pandor
121121
let publisher = subject.eraseToAnyPublisher()
122122
lock.unlock()
123123

124-
return publisher
124+
if emitInitial {
125+
return publisher
126+
} else {
127+
return publisher.dropFirst().eraseToAnyPublisher()
128+
}
125129
}
126130

127131
public func clear() {

Sources/Pandora/MemoryBox/PandoraMemoryBoxProtocol.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@ public protocol PandoraMemoryBoxProtocol {
2929
func clear()
3030

3131
/// Returns a publisher that emits the current and subsequent values for the given key.
32-
func publisher(for key: Key) -> AnyPublisher<Value?, Never>
32+
/// - Parameters:
33+
/// - key: The cache key to observe.
34+
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
35+
func publisher(for key: Key, emitInitial: Bool) -> AnyPublisher<Value?, Never>
3336
}

Sources/Pandora/UserDefaultsBox/PandoraUserDefaultsBox.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,17 @@ public final class PandoraUserDefaultsBox<Value: Codable & Sendable>: PandoraDef
115115
// MARK: - Publisher
116116

117117
/// Returns a publisher that emits the current and future values for the specified key.
118-
public func publisher(for key: String) -> AnyPublisher<Value?, Never> {
119-
memory.publisher(for: key)
118+
public func publisher(for key: String, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
119+
if emitInitial {
120+
let currentValue = syncLock.withLock { memory.get(key) }
121+
return Publishers.Merge(
122+
Just(currentValue).eraseToAnyPublisher(),
123+
memory.publisher(for: key, emitInitial: false)
124+
)
125+
.eraseToAnyPublisher()
126+
} else {
127+
return memory.publisher(for: key, emitInitial: false)
128+
}
120129
}
121130

122131
// MARK: - Get

Sources/Pandora/UserDefaultsBox/PandoraUserDefaultsBoxProtocol.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public protocol PandoraDefaultsBoxProtocol {
1818
var namespace: String { get }
1919

2020
/// Emits the current and future values for the given key.
21-
func publisher(for key: String) -> AnyPublisher<Value?, Never>
21+
/// - Parameters:
22+
/// - key: The cache key to observe.
23+
/// - emitInitial: Whether to emit the current value immediately upon subscription. Defaults to `true`.
24+
func publisher(for key: String, emitInitial: Bool) -> AnyPublisher<Value?, Never>
2225

2326
/// Reads the value for the given key, checking memory first, then UserDefaults,
2427
/// and iCloud if enabled.

Tests/PandoraTests/HybridBox/HybridBoxTests.swift

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,5 +138,141 @@ final class HybridBoxTests: XCTestCase {
138138
MockHybridBox<String, String>.clearAll()
139139
// No state to verify, should not crash
140140
}
141+
142+
// MARK: - emitInitial Tests
143+
144+
func test_givenValueExists_whenPublisherWithEmitInitialTrue_thenEmitsCurrentValueImmediately() {
145+
// Given
146+
let mock = MockHybridBox<String, String>()
147+
mock.put(key: "test", value: "hello", expiresAfter: nil)
148+
let expectation = XCTestExpectation(description: "Publisher emits current value")
149+
var received: [String?] = []
150+
151+
// When
152+
mock.publisher(for: "test", emitInitial: true)
153+
.sink { value in
154+
received.append(value)
155+
if received.count == 1 {
156+
expectation.fulfill()
157+
}
158+
}
159+
.store(in: &cancellables)
160+
161+
// Then
162+
wait(for: [expectation], timeout: 1)
163+
XCTAssertEqual(received, ["hello"])
164+
}
165+
166+
func test_givenValueExists_whenPublisherWithEmitInitialFalse_thenDoesNotEmitCurrentValue() {
167+
// Given
168+
let mock = MockHybridBox<String, String>()
169+
mock.put(key: "test", value: "hello", expiresAfter: nil)
170+
let expectation = XCTestExpectation(description: "Publisher does not emit current value")
171+
var received: [String?] = []
172+
173+
// When
174+
mock.publisher(for: "test", emitInitial: false)
175+
.sink { value in
176+
received.append(value)
177+
// Should not be called immediately
178+
}
179+
.store(in: &cancellables)
180+
181+
// Wait a bit to ensure no immediate emission
182+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
183+
expectation.fulfill()
184+
}
185+
186+
// Then
187+
wait(for: [expectation], timeout: 1)
188+
XCTAssertTrue(received.isEmpty)
189+
}
190+
191+
func test_givenValueExists_whenPublisherWithEmitInitialFalse_thenEmitsFutureUpdates() {
192+
// Given
193+
let mock = MockHybridBox<String, String>()
194+
mock.put(key: "test", value: "hello", expiresAfter: nil)
195+
let expectation = XCTestExpectation(description: "Publisher emits future updates")
196+
var received: [String?] = []
197+
198+
mock.publisher(for: "test", emitInitial: false)
199+
.sink { value in
200+
received.append(value)
201+
if received.count == 2 { // Should get "world", then nil
202+
expectation.fulfill()
203+
}
204+
}
205+
.store(in: &cancellables)
206+
207+
// When
208+
mock.put(key: "test", value: "world", expiresAfter: nil)
209+
mock.remove("test")
210+
211+
// Then
212+
wait(for: [expectation], timeout: 1)
213+
XCTAssertEqual(received, ["world", nil])
214+
}
215+
216+
func test_givenNoValue_whenPublisherWithEmitInitialTrue_thenEmitsNilImmediately() {
217+
// Given
218+
let mock = MockHybridBox<String, String>()
219+
let expectation = XCTestExpectation(description: "Publisher emits nil immediately")
220+
var received: [String?] = []
221+
222+
// When
223+
mock.publisher(for: "missing", emitInitial: true)
224+
.sink { value in
225+
received.append(value)
226+
expectation.fulfill()
227+
}
228+
.store(in: &cancellables)
229+
230+
// Then
231+
wait(for: [expectation], timeout: 1)
232+
XCTAssertEqual(received, [nil])
233+
}
234+
235+
func test_givenNoValue_whenPublisherWithEmitInitialFalse_thenDoesNotEmitInitially() {
236+
// Given
237+
let mock = MockHybridBox<String, String>()
238+
let expectation = XCTestExpectation(description: "Publisher does not emit initially")
239+
var received: [String?] = []
240+
241+
mock.publisher(for: "missing", emitInitial: false)
242+
.sink { value in
243+
received.append(value)
244+
// Should not be called initially
245+
}
246+
.store(in: &cancellables)
247+
248+
// Wait to ensure no immediate emission
249+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
250+
expectation.fulfill()
251+
}
252+
253+
// Then
254+
wait(for: [expectation], timeout: 1)
255+
XCTAssertTrue(received.isEmpty)
256+
}
257+
258+
func test_givenDefaultEmitInitial_whenPublisherCalled_thenEmitsCurrentValue() {
259+
// Given
260+
let mock = MockHybridBox<String, String>()
261+
mock.put(key: "default", value: "test", expiresAfter: nil)
262+
let expectation = XCTestExpectation(description: "Default behavior emits current value")
263+
var received: [String?] = []
264+
265+
// When (using default parameter)
266+
mock.publisher(for: "default")
267+
.sink { value in
268+
received.append(value)
269+
expectation.fulfill()
270+
}
271+
.store(in: &cancellables)
272+
273+
// Then
274+
wait(for: [expectation], timeout: 1)
275+
XCTAssertEqual(received, ["test"])
276+
}
141277
}
142278

Tests/PandoraTests/HybridBox/Mocks/MockHybridBox.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ final class MockHybridBox<Key: Hashable & Codable & Sendable, Value: Codable & S
2020
self.namespace = namespace
2121
}
2222

23-
func publisher(for key: Key) -> AnyPublisher<Value?, Never> {
24-
lock.withLock {
23+
func publisher(for key: Key, emitInitial: Bool = true) -> AnyPublisher<Value?, Never> {
24+
let subject = lock.withLock {
2525
if publishers[key] == nil {
2626
publishers[key] = CurrentValueSubject<Value?, Never>(storage[key])
2727
}
28-
return publishers[key]!.eraseToAnyPublisher()
28+
return publishers[key]!
29+
}
30+
31+
if emitInitial {
32+
return subject.eraseToAnyPublisher()
33+
} else {
34+
return subject.dropFirst().eraseToAnyPublisher()
2935
}
3036
}
3137

0 commit comments

Comments
 (0)