()
188 |
189 | stream.sink { value in
190 | await rec.append(value)
191 | }
192 | .store(in: &tasks)
193 |
194 | // THEN the current value (12) is replayed immediately
195 | try? await Task.sleep(nanoseconds: 40_000_000)
196 | #expect(await rec.snapshot() == [12])
197 |
198 | // WHEN a subsequent change occurs
199 | await MainActor.run { counter.count = 13 }
200 | try? await Task.sleep(nanoseconds: 40_000_000)
201 |
202 | // THEN it appends after the replay
203 | #expect(await rec.snapshot() == [12, 13])
204 | }
205 |
206 | }
207 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # AsyncCombine
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | AsyncCombine brings familiar Combine-style operators like `sink`, `assign`, and `store(in:)` to the world of Swift Concurrency.
21 |
22 | While Swift Concurrency has certainly been an improvement over Combine when combined (heh) with swift-async-algorithms, managing multiple subscriptions can be quite a messy process.
23 |
24 | Introducing, AsyncCombine! It’s built on top of `AsyncSequence` and integrated with Swift’s `Observation` framework, so you can react to `@Observable` model changes, bind values to UI, and manage state, all without importing Combine. Beacuse of this, it works on any platform that Swift runs on, from iOS and macOS to Linux and SwiftWasm.
25 |
26 | It also ships with CurrentValueRelay, a replay-1 async primitive inspired by Combine’s `CurrentValueSubject`, giving you a simple way to bridge stateful streams between domain logic and presentation.
27 |
28 | While async/await brought clarity and safety to Swift’s concurrency story, working directly with AsyncSequence can sometimes feel verbose and clunky, especially when compared to Combine’s elegant, declarative pipelines. With Combine, you chain operators fluently (map → filter → sink) and manage lifetimes in one place. By contrast, async/await often forces you into nested for await loops, manual task management, and boilerplate cancellation. AsyncCombine bridges this gap: it keeps the expressive syntax and ergonomics of Combine while running entirely on Swift Concurrency. You get the readability of Combine pipelines, without the overhead of pulling in Combine itself or losing portability.
29 |
30 | Let's get into the nuts and bolts of it all and look into how AsyncCombine can improve your Swift Concurrency exerience.
31 |
32 | So say you have a View Model like below.
33 | ```swift
34 | @Observable @MainActor
35 | final class CounterViewModel {
36 | var count: Int = 0
37 | }
38 | ```
39 |
40 | In a traditional async/await setup, you would listen to the new value being published like so.
41 | ```swift
42 | let viewModel = CounterViewModel()
43 |
44 | let countChanges = Observations {
45 | self.viewModel.count
46 | }
47 |
48 | Task {
49 | for await count in countChanges.map({ "Count: \($0)" }) {
50 | print("\(count)")
51 | }
52 | }
53 | ```
54 |
55 | However with AsyncCombine you can express the same logic in a more concise and easy to read format.
56 | ```swift
57 | var subscriptions = Set()
58 |
59 | viewModel.observed(\.count)
60 | .map { "Count: \($0)" }
61 | .sink { print($0) }
62 | .store(in: &subscriptions)
63 | ```
64 |
65 |
66 | ## ✨ Features
67 |
68 | ### 🔗 Combine-like Syntax
69 | - Write familiar, declarative pipelines without pulling in Combine.
70 | - Use `.sink {}` to respond to values from any `AsyncSequence`.
71 | - Use `.assign(to:on:)` to bind values directly to object properties (e.g. `label.textColor`).
72 | - Manage lifetimes with `.store(in:)` on Task sets, just like `AnyCancellable`.
73 |
74 | ### 👀 Observation Integration
75 | - Seamlessly connect to Swift’s new Observation framework.
76 | - Turn `@Observable` properties into streams with `observed(\.property)`.
77 | - Automatically replay the current value, then emit fresh values whenever the property changes.
78 | - Perfect for keeping UI state in sync with your models.
79 |
80 | ### 🔁 CurrentValueSubject Replacement
81 | - Ship values through your app with a hot, replay-1 async primitive.
82 | - `CurrentValueRelay` holds the latest value and broadcasts it to all listeners.
83 | - Similar to Combine’s `CurrentValueSubject`, but actor-isolated and async-first.
84 | - Exposes an AsyncStream for easy consumption in UI or domain code.
85 |
86 | ### 🔗 Publishers.CombineLatest Replacement
87 | - Pair two AsyncSequences and emit the latest tuple whenever either side produces a new element (after both have emitted at least once).
88 | - Finishes when both upstream sequences finish (CombineLatest semantics).
89 | - Cancellation of the downstream task cancels both upstream consumers.
90 | - Plays nicely with Swift Async Algorithms (e.g. you can map, debounce, etc. before/after).
91 |
92 | ### ⚡ Async Algorithms Compatible
93 | - Compose richer pipelines using Swift Async Algorithms.
94 | - Add `.debounce`, `.throttle`, `.merge`, `.zip`, and more to your async streams.
95 | - Chain seamlessly with AsyncCombine operators (`sink`, `assign`, etc.).
96 | - Great for smoothing UI inputs, combining event streams, and building complex state machines.
97 |
98 | ### 🌍 Cross-Platform
99 | - AsyncCombine doesn’t rely on Combine or other Apple-only frameworks.
100 | - Runs anywhere Swift Concurrency works: iOS, macOS, tvOS, watchOS.
101 | - Fully portable to Linux and even SwiftWasm for server-side and web targets.
102 | - Ideal for writing platform-agnostic domain logic and unit tests.
103 |
104 | ## 🚀 Usage
105 |
106 | ### Observe @Observable properties
107 | Turn any `@Observable` property into an `AsyncStream` that replays the current value and then emits on every change. Chain standard `AsyncSequence` operators (`map`, `filter`, `compactMap`, ...) and finish with `sink` or `assign`.
108 |
109 | ```swift
110 | import AsyncCombine
111 | import Observation
112 |
113 | @Observable @MainActor
114 | final class CounterViewModel {
115 | var count: Int = 0
116 | }
117 |
118 | let viewModel = CounterViewModel()
119 | var subscriptions = Set()
120 |
121 | // $viewModel.count → viewModel.observed(\.count)
122 | viewModel.observed(\.count)
123 | .map { "Count: \($0)" }
124 | .sink { print($0) }
125 | .store(in: &subscriptions)
126 |
127 | viewModel.count += 1 // prints "Count: 1"
128 | ```
129 |
130 | Why it works: `observed(_:)` uses `withObservationTracking` under the hood and reads on `MainActor`, so you always get the fresh value (no stale reads).
131 |
132 | ### Bind to UI (UIKit / AppKit / SpriteKit / custom objects)
133 |
134 | ```swift
135 | // UILabel example
136 | let label = UILabel()
137 |
138 | viewModel.observed(\.count)
139 | .map {
140 | UIColor(
141 | hue: CGFloat($0 % 360) / 360,
142 | saturation: 1,
143 | brightness: 1,
144 | alpha: 1
145 | )
146 | }
147 | .assign(to: \.textColor, on: label)
148 | .store(in: &subscriptions)
149 | ```
150 | Works the same for `NSTextField.textColor, `SKShapeNode.fillColor`, your own class properties, etc.
151 |
152 | ### Use CurrentValueRelay for hot, replay-1 state
153 |
154 | `CurrentValueRelay` holds the latest value and broadcasts it to all listeners. `stream()` yields the current value immediately, then subsequent updates.
155 |
156 | ```swift
157 | let relay = CurrentValueRelay(false)
158 | var subs = Set()
159 |
160 | relay.stream()
161 | .map { $0 ? "ON" : "OFF" }
162 | .sink { print($0) } // "OFF" immediately (replay)
163 | .store(in: &subs)
164 |
165 | Task {
166 | await relay.send(true) // prints "ON"
167 | await relay.send(false) // prints "OFF"
168 | }
169 | ```
170 |
171 | Cancel tasks when you’re done (e.g., deinit).
172 |
173 | ```swift
174 | subs.cancelAll()
175 | ```
176 |
177 | ### Combine multiple AsyncSequences into a single AsyncSequence
178 |
179 | ```swift
180 | import AsyncAlgorithms
181 | import AsyncCombine
182 |
183 | // Two arbitrary async streams
184 | let a = AsyncStream { cont in
185 | Task {
186 | for i in 1...3 {
187 | try? await Task.sleep(nanoseconds: 100_000_000)
188 | cont.yield(i) // 1, 2, 3
189 | }
190 | cont.finish()
191 | }
192 | }
193 |
194 | let b = AsyncStream { cont in
195 | Task {
196 | for s in ["A", "B"] {
197 | try? await Task.sleep(nanoseconds: 150_000_000)
198 | cont.yield(s) // "A", "B"
199 | }
200 | cont.finish()
201 | }
202 | }
203 |
204 | // combineLatest-style pairing
205 | var tasks = Set()
206 |
207 | AsyncCombine.CombineLatest(a, b)
208 | .map { i, s in "Pair: \(i) & \(s)" }
209 | .sink { print($0) }
210 | .store(in: &tasks)
211 |
212 | // Prints (timing-dependent, after both have emitted once):
213 | // "Pair: 2 & A"
214 | // "Pair: 3 & A"
215 | // "Pair: 3 & B"
216 |
217 | ```
218 |
219 | ### Debounce, throttle, merge (with Swift Async Algorithms)
220 |
221 | AsyncCombine plays nicely with [Swift Async Algorithms]. Import it to get reactive operators you know from Combine.
222 |
223 | ```swift
224 | import AsyncAlgorithms
225 |
226 | viewModel.observed(\.count)
227 | .debounce(for: .milliseconds(250)) // smooth noisy inputs
228 | .map { "Count: \($0)" }
229 | .sink { print($0) }
230 | .store(in: &subscriptions)
231 | ```
232 | You can also `merge` multiple streams, `zip` them, `removeDuplicates`, etc.
233 |
234 | ### Lifecycle patterns (Combine-style ergonomics)
235 |
236 | Keep your subscriptions alive as long as you need them:
237 | ```swift
238 | final class Monitor {
239 | private var subscriptions = Set()
240 | private let vm: CounterViewModel
241 |
242 | init(vm: CounterViewModel) {
243 | self.vm = vm
244 |
245 | vm.observed(\.count)
246 | .map(String.init)
247 | .sink { print("Count:", $0) }
248 | .store(in: &subscriptions)
249 | }
250 |
251 | deinit {
252 | subscriptions.cancelAll()
253 | }
254 | }
255 | ```
256 |
257 | ### Handle throwing streams (works for both throwing & non-throwing)
258 |
259 | `sink(catching:_:)` uses an iterator under the hood, so you can consume throwing sequences too. If your pipeline introduces errors, add an error handler:
260 |
261 | ```swift
262 | someThrowingAsyncSequence // AsyncSequence whose iterator `next()` can throw
263 | .map { $0 } // your transforms here
264 | .sink(catching: { error in
265 | print("Stream error:", error)
266 | }) { value in
267 | print("Value:", value)
268 | }
269 | .store(in: &subscriptions)
270 | ```
271 | If your stream is non-throwing (e.g., `AsyncStream`, `relay.stream()`), just omit `catching:`.
272 |
273 | ### Quick Reference
274 |
275 | - `observed(\.property)` → `AsyncStream` (replay-1, Observation-backed)
276 | - `sink { value in … }` → consume elements (returns Task you can cancel or `.store(in:)`)
277 | - `assign(to:on:)` → main-actor property binding
278 | - `CurrentValueRelay` → `send(_:)`, `stream(replay: true)`
279 | - `subscriptions.cancelAll()` → cancel everything (like clearing AnyCancellables)
280 |
281 | ### SwiftUI Tip
282 | SwiftUI already observes `@Observable` models. You usually don’t need `observed(_:)` inside a View for simple UI updates—bind directly to the model. Use `observed(_:)` when you need pipelines (`debounce`, `merge`, etc) or when binding to non-SwiftUI objects (eg., SpriteKit, UIKit).
283 |
284 | ## 🧪 Testing
285 |
286 | AsyncCombine ships with lightweight testing utilities that make it easy to record, inspect, and assert values emitted by AsyncSequences.
287 | This lets you write deterministic async tests without manual loops, sleeps, or boilerplate cancellation logic.
288 |
289 | ### 📹 Testing with Recorder
290 |
291 | The `Recorder` class helps you capture and assert values emitted by any `AsyncSequence`.
292 |
293 | It continuously consumes the sequence on a background task and buffers each element, allowing your test to await them one by one with predictable timing.
294 |
295 | ```swift
296 | import AsyncCombine
297 | import Testing // or XCTest
298 |
299 | @Test
300 | func testRelayEmitsExpectedValues() async throws {
301 | let relay = CurrentValueRelay(0)
302 | let recorder = relay.stream().record()
303 |
304 | await relay.send(1)
305 | await relay.send(2)
306 |
307 | let first = try await recorder.next()
308 | let second = try await recorder.next()
309 |
310 | #expect(first == 1)
311 | #expect(second == 2)
312 | }
313 | ```
314 |
315 | `Recorder` makes it easy to verify asynchronous behaviour without juggling timers or nested loops.
316 |
317 | If the next value doesn’t arrive within the timeout window, it automatically reports a failure (via `Issue.record` or `XCTFail`, depending on your test framework).
318 |
319 | ### 🥇 Finding the First Matching Value
320 |
321 | `AsyncCombine` also extends `AsyncSequence` with a convenience helper for asserting specific values without fully consuming the sequence.
322 |
323 | ```swift
324 | import AsyncCombine
325 | import Testing
326 |
327 | @Test
328 | func testStreamEmitsSpecificValue() async throws {
329 | let stream = AsyncStream { cont in
330 | cont.yield(1)
331 | cont.yield(2)
332 | cont.yield(3)
333 | cont.finish()
334 | }
335 |
336 | // Wait for the first element equal to 2.
337 | let match = await stream.first(equalTo: 2)
338 | #expect(match == 2)
339 | }
340 | ```
341 |
342 | This suspends until the first matching element arrives, or returns nil if the sequence finishes first.
343 |
344 | It’s ideal when you just need to confirm that a certain value appears somewhere in an async stream.
345 |
346 | ## 📦 Installation
347 |
348 | Add this to your Package.swift:
349 | ```swift
350 | dependencies: [
351 | .package(url: "https://github.com/will-lumley/AsyncCombine.git", from: "1.0.3")
352 | ]
353 | ```
354 |
355 | Or in Xcode: File > Add Packages... and paste the repo URL.
356 |
357 | ## Author
358 |
359 | [William Lumley](https://lumley.io/), will@lumley.io
360 |
361 | ## License
362 |
363 | AsyncCombine is available under the MIT license. See the LICENSE file for more info.
364 |
--------------------------------------------------------------------------------