Compare commits

..

5 commits
0.3.1 ... main

Author SHA1 Message Date
c2a231a40f
Fix a new warning for Swift 6.2 2025-11-05 08:38:35 -08:00
bb8d04b54f
Document everything (#2)
* Document everything

* Make sure ReadmeExamples are up to date
2025-06-13 11:37:02 -07:00
2d4c37d9da
Merge pull request #1 from samsonjs/xcode26
Fixes for Swift 6.2 on iOS 26 and macOS 26
2025-06-13 11:34:50 -07:00
52b10585ab
Fixes for Swift 6.2 on iOS 26 and macOS 26 2025-06-11 08:36:44 -07:00
33abeebe52
Add a changelog 2025-06-08 21:01:03 -07:00
12 changed files with 569 additions and 69 deletions

77
Changelog.md Normal file
View file

@ -0,0 +1,77 @@
# Changelog
## [Unreleased]
- Your change here.
[Unreleased]: https://github.com/samsonjs/AsyncMonitor/compare/0.3.1...HEAD
## [0.3.1] - 2025-05-25
### Changed
- Updated documentation in Readme.md
[0.3.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.3...0.3.1
## [0.3] - 2025-05-25
### Added
- Support for iOS 17 and macOS 14 (expanded platform compatibility)
- Legacy initializers and monitor methods with Sendable requirements for iOS 17+ compatibility
- Backward compatibility layer for actor isolation features
### Changed
- Enhanced AsyncMonitor class with dual initializer pattern for different iOS versions
- Improved AsyncSequence extensions with version-specific monitor methods
- Updated NSObject+AsyncKVO implementation for broader platform support
[0.3]: https://github.com/samsonjs/AsyncMonitor/compare/0.2.1...0.3
## [0.2.1] - 2025-04-26
### Changed
- **Breaking**: Refactored KVO monitoring API
- Split `values` method into separate `values(for:)` method that returns AsyncStream
- Added `monitorValues(for:)` convenience method that combines values observation with monitoring
- Replaced `TokenLocker` with `ValueLocker` for improved value management
### Added
- Enhanced test coverage for NSObject+AsyncKVO functionality
- Additional test cases for async cancellable behavior
[0.2.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.2...0.2.1
## [0.2] - 2025-04-26
### Changed
- Version bump to 0.2
[0.2]: https://github.com/samsonjs/AsyncMonitor/compare/0.1.1...0.2
## [0.1.1] - 2025-04-25
### Changed
- Updated minimum iOS platform requirement to 18.0
- Removed main actor restrictions from public API
### Added
- Comprehensive documentation comments on public API
- Enhanced README with detailed usage examples and patterns
- Expanded test suite coverage
[0.1.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.1...0.1.1
## [0.1] - 2025-04-25
### Added
- Initial release of AsyncMonitor
- Core `AsyncMonitor` class for wrapping async sequence observation in manageable Tasks
- `AsyncCancellable` protocol and `AnyAsyncCancellable` type-eraser for uniform cancellation
- AsyncSequence extensions with `.monitor()` convenience methods
- KVO integration via `NSObject+AsyncKVO` extension
- Support for context-aware monitoring to prevent reference cycles
- Swift Testing framework integration
- Comprehensive test suite
- Documentation and usage examples
[0.1]: https://github.com/samsonjs/AsyncMonitor/releases/tag/0.1

View file

@ -32,8 +32,8 @@ class SimplestVersion {
This example uses the context parameter to avoid reference cycles with `self`.
```swift
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
final class WithContext: Sendable {
nonisolated(unsafe) var cancellables = Set<AnyAsyncCancellable>()
init() {
NotificationCenter.default
@ -55,10 +55,10 @@ class WithContext {
Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values].
```swift
import Combine
@preconcurrency import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
@ -66,8 +66,7 @@ class CombineExample {
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}.store(in: &cancellables)
}
}
```
@ -80,11 +79,11 @@ When you need to observe an object that uses [KVO][] there's an extension method
```swift
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
let progress = Progress(totalUnitCount: 42)
progress.monitorValues(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}

View file

@ -1,10 +1,19 @@
/// Type-erasing wrapper for ``AsyncCancellable`` that ties its instance lifetime to cancellation. In other words, when you release
/// an instance of ``AnyAsyncCancellable`` and it's deallocated then it automatically cancels its given ``AsyncCancellable``.
/// Type-erasing wrapper for ``AsyncCancellable`` that automatically cancels when deallocated.
///
/// `AnyAsyncCancellable` provides automatic cancellation when deallocated, making it safe to store
/// cancellables without explicitly managing their lifecycle.
///
public class AnyAsyncCancellable: AsyncCancellable {
lazy var id = ObjectIdentifier(self)
let canceller: () -> Void
/// Creates a type-erased wrapper around the provided cancellable.
///
/// The wrapper will call the cancellable's `cancel()` method when either
/// explicitly cancelled or deallocated.
///
/// - Parameter cancellable: The ``AsyncCancellable`` to wrap.
public init<AC: AsyncCancellable>(cancellable: AC) {
canceller = { cancellable.cancel() }
}
@ -15,6 +24,7 @@ public class AnyAsyncCancellable: AsyncCancellable {
// MARK: AsyncCancellable conformance
/// Cancels the wrapped cancellable. Safe to call multiple times and automatically called on deallocation.
public func cancel() {
canceller()
}

View file

@ -1,10 +1,15 @@
/// Represents an async operation that can be cancelled.
///
/// `AsyncCancellable` provides a common interface for cancelling async operations, similar to
/// Combine's `AnyCancellable` but designed for Swift concurrency patterns.
///
public protocol AsyncCancellable: AnyObject, Hashable {
/// Cancels the operation.
/// Cancels the operation. Safe to call multiple times.
func cancel()
/// Stores this cancellable in the given set, using the type-erasing wrapper ``AnyAsyncCancellable``. This method has a
/// default implementation and you typically shouldn't need to override it.
/// Stores this cancellable in the given set using ``AnyAsyncCancellable``.
///
/// - Parameter set: The set to store the wrapped cancellable in.
func store(in set: inout Set<AnyAsyncCancellable>)
}

View file

@ -0,0 +1,112 @@
# ``AsyncMonitor``
Wraps async sequence observation in manageable tasks.
## Overview
AsyncMonitor wraps async sequence observation in a `Task` that can be cancelled and stored. It preserves actor isolation on iOS 18+ and includes KVO integration.
## Basic Usage
```swift
import AsyncMonitor
// Monitor notifications
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in print("Day changed!") }
// Store for longer lifetime
var cancellables: Set<AnyAsyncCancellable> = []
sequence.monitor { element in
// Handle element
}.store(in: &cancellables)
```
## Context-Aware Monitoring
Prevent retain cycles with weak context:
```swift
class DataController {
var cancellables: Set<AnyAsyncCancellable> = []
func startMonitoring() {
dataStream
.monitor(context: self) { controller, data in
controller.processData(data)
}.store(in: &cancellables)
}
}
```
## KVO Integration
```swift
let progress = Progress(totalUnitCount: 100)
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress: \(fraction.formatted(.percent))")
}.store(in: &cancellables)
```
## Error Handling
Both throwing and non-throwing sequences work. Errors are caught and logged automatically.
```swift
// Non-throwing
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.values
.monitor { date in print("Timer: \(date)") }
// Throwing (errors caught automatically)
networkDataStream()
.monitor { data in processData(data) }
```
## Memory Management
Use weak captures or context to avoid retain cycles:
```swift
// Good
sequence.monitor(context: self) { controller, element in
controller.handle(element)
}
// Good
sequence.monitor { [weak self] element in
self?.handle(element)
}
// Bad - creates retain cycle
sequence.monitor { element in
self.handle(element)
}
```
## Platform Requirements
- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
## Topics
### Core Types
- ``AsyncMonitor`` - Wraps async sequence observation in a managed Task
- ``AsyncCancellable`` - Protocol for async operations that can be cancelled
- ``AnyAsyncCancellable`` - Type-erasing wrapper that auto-cancels on deallocation
### Sequence Extensions
- ``Foundation/AsyncSequence/monitor(_:)``
- ``Foundation/AsyncSequence/monitor(context:_:)``
### KVO Integration
- ``Foundation/NSObjectProtocol/monitorValues(for:options:changeHandler:)``

View file

@ -1,26 +1,27 @@
/// A monitor that observes an asynchronous sequence and invokes the given block for each received element.
///
/// The element must be `Sendable` so to use it to monitor notifications from `NotificationCenter` you'll need to map them to
/// something sendable before calling `monitor` on the sequence. e.g.
/// `AsyncMonitor` wraps the observation of an async sequence in a `Task`, providing automatic cancellation
/// and memory management. Elements must be `Sendable`. For notifications, map to something sendable:
///
/// ```
/// ```swift
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in whatever() }
/// .store(in: &cancellables)
/// .monitor { _ in print("Day changed!") }
/// ```
///
/// On iOS 18+, preserves the caller's actor isolation context by default.
///
public final class AsyncMonitor: Hashable, AsyncCancellable {
let task: Task<Void, Never>
/// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence.
/// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence with actor isolation support (iOS 18+).
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit.
/// Defaults to `#isolation`, preserving the caller's actor isolation.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`.
/// - sequence: The asynchronous sequence of elements to observe.
/// - block: A closure to execute for each element yielded by the sequence.
@available(iOS 18, *)
@available(iOS 18, macOS 15, *)
public init<Element: Sendable>(
isolation: isolated (any Actor)? = #isolation,
sequence: any AsyncSequence<Element, Never>,
@ -35,16 +36,42 @@ public final class AsyncMonitor: Hashable, AsyncCancellable {
}
}
/// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence.
/// Creates an ``AsyncMonitor`` for sequences that may throw errors (iOS 18+).
///
/// - Parameters:
/// - sequence: The asynchronous sequence of elements to observe.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`.
/// - sequence: The asynchronous sequence of elements to observe. May throw errors.
/// - block: A closure to execute for each element yielded by the sequence.
@available(iOS 18, macOS 15, *)
public init<Element: Sendable, Sequence: AsyncSequence>(
isolation: isolated (any Actor)? = #isolation,
sequence: Sequence,
performing block: @escaping (Element) async -> Void
) where Sequence.Element == Element {
self.task = Task {
_ = isolation // use capture trick to inherit isolation
do {
for try await element in sequence {
await block(element)
}
} catch {
guard !Task.isCancelled else { return }
}
}
}
/// Creates an ``AsyncMonitor`` for iOS 17 compatibility.
///
/// - Parameters:
/// - sequence: The asynchronous sequence of elements to observe. Must be `Sendable`.
/// - block: A `@Sendable` closure to execute for each element yielded by the sequence.
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public init<Element: Sendable, Sequence>(
sequence: sending Sequence,
@_inheritActorContext performing block: @escaping @Sendable (Element) async -> Void
) where Sequence: AsyncSequence, Element == Sequence.Element {
) where Sequence: AsyncSequence & Sendable, Sequence.Element == Element {
self.task = Task {
do {
for try await element in sequence {
@ -64,7 +91,7 @@ public final class AsyncMonitor: Hashable, AsyncCancellable {
// MARK: AsyncCancellable conformance
/// Cancels the underlying task monitoring the asynchronous sequence.
/// Cancels the underlying task. Safe to call multiple times and automatically called when deallocated.
public func cancel() {
task.cancel()
}

View file

@ -1,12 +1,36 @@
@available(iOS 18, *)
@available(iOS 18, macOS 15, *)
public extension AsyncSequence where Element: Sendable, Failure == Never {
/// Observes the elements yielded by this sequence and executes the given closure with each element.
///
/// This method preserves the actor isolation of the caller by default when `isolation` is not specified.
/// This method creates an ``AsyncMonitor`` that observes the sequence and preserves the caller's
/// actor isolation context by default. When called from a `@MainActor` context, the monitoring
/// block will also run on the main actor.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`, preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// @MainActor class ViewModel {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Monitor runs on MainActor since caller is @MainActor
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in
/// self.updateUI() // Safe to call @MainActor methods
/// }.store(in: &cancellables)
/// }
/// }
/// ```
func monitor(
isolation: isolated (any Actor)? = #isolation,
_ block: @escaping (Element) async -> Void
@ -14,15 +38,120 @@ public extension AsyncSequence where Element: Sendable, Failure == Never {
AsyncMonitor(isolation: isolation, sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element the weakly-captured
/// context object.
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object.
///
/// This method preserves the actor isolation of the caller by default when `isolation` is not specified.
/// This method creates an ``AsyncMonitor`` that weakly captures the provided context object, preventing retain cycles.
/// If the context object is deallocated, the monitoring block will not be executed for subsequent elements.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`, preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure.
/// - block: A closure that's executed with each yielded element, and the `context`.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure. This prevents retain cycles
/// when the context holds a reference to the monitor.
/// - block: A closure that's executed with the weakly-captured context and each yielded element.
/// The closure runs with the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class DataManager {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Context is weakly captured, preventing retain cycle
/// dataStream
/// .monitor(context: self) { manager, data in
/// manager.process(data) // manager won't be nil here
/// }.store(in: &cancellables)
/// }
///
/// func process(_ data: Data) {
/// // Process the data
/// }
/// }
/// ```
func monitor<Context: AnyObject>(
isolation: isolated (any Actor)? = #isolation,
context: Context,
_ block: @escaping (Context, Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(isolation: isolation, sequence: self) { [weak context] element in
guard let context else { return }
await block(context, element)
}
}
}
@available(iOS 18, macOS 15, *)
public extension AsyncSequence where Element: Sendable {
/// Observes the elements yielded by this sequence and executes the given closure with each element.
///
/// This method creates an ``AsyncMonitor`` that observes the sequence and preserves the caller's
/// actor isolation context by default. When called from a `@MainActor` context, the monitoring
/// block will also run on the main actor.
///
/// This version handles sequences that may throw errors. If an error is thrown, it will be logged
/// and monitoring will stop.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in
/// print("Day changed!")
/// }.store(in: &cancellables)
/// ```
func monitor(
isolation: isolated (any Actor)? = #isolation,
_ block: @escaping (Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(isolation: isolation, sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object.
///
/// This method creates an ``AsyncMonitor`` that weakly captures the provided context object, preventing retain cycles.
/// If the context object is deallocated, the monitoring block will not be executed for subsequent elements.
///
/// This version handles sequences that may throw errors. If an error is thrown, it will be logged
/// and monitoring will stop.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure. This prevents retain cycles
/// when the context holds a reference to the monitor.
/// - block: A closure that's executed with the weakly-captured context and each yielded element.
/// The closure runs with the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class DataManager {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// notificationStream
/// .monitor(context: self) { manager, notification in
/// manager.handleNotification(notification)
/// }.store(in: &cancellables)
/// }
/// }
/// ```
func monitor<Context: AnyObject>(
isolation: isolated (any Actor)? = #isolation,
context: Context,
@ -36,23 +165,71 @@ public extension AsyncSequence where Element: Sendable, Failure == Never {
}
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public extension AsyncSequence where Self: Sendable, Element: Sendable {
/// Observes the elements yielded by this sequence and executes the given closure with each element.
/// Observes the elements yielded by this sequence and executes the given closure with each element (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17. It requires both the sequence and its elements
/// to be `Sendable`, and uses a `@Sendable` closure for thread safety.
///
/// - Parameters:
/// - block: A closure that's executed with each yielded element.
/// - block: A `@Sendable` closure that's executed with each yielded element.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// let cancellable = sendableAsyncSequence.monitor { element in
/// print("Received: \(element)")
/// }
///
/// // Store for automatic cleanup
/// cancellable.store(in: &cancellables)
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of ``monitor(isolation:_:)``
/// which provides better actor isolation support.
func monitor(
_ block: @escaping @Sendable (Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element the weakly-captured
/// context object.
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17 with weak reference handling to prevent retain cycles.
/// It requires the context to be both `AnyObject` and `Sendable` for thread safety.
///
/// - Parameters:
/// - context: The object to capture weakly for use within the closure.
/// - block: A closure that's executed with each yielded element, and the `context`.
/// - context: The object to capture weakly for use within the closure. Must be `Sendable` and will be
/// captured weakly to prevent retain cycles.
/// - block: A `@Sendable` closure that's executed with the weakly-captured context and each yielded element.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class SendableDataManager: Sendable {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Context is weakly captured, preventing retain cycle
/// sendableDataStream
/// .monitor(context: self) { manager, data in
/// manager.process(data)
/// }.store(in: &cancellables)
/// }
///
/// func process(_ data: Data) {
/// // Process the data
/// }
/// }
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of ``monitor(isolation:context:_:)``
/// which provides better actor isolation support.
func monitor<Context: AnyObject & Sendable>(
context: Context,
_ block: @escaping @Sendable (Context, Element) async -> Void

View file

@ -3,11 +3,40 @@ public import Foundation
extension KeyPath: @unchecked @retroactive Sendable where Value: Sendable {}
public extension NSObjectProtocol where Self: NSObject {
/// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object.
/// Returns an `AsyncSequence` of values for all changes to the given key path on this object.
///
/// This method creates an `AsyncStream` that yields the current value of the specified key path
/// whenever it changes via Key-Value Observing (KVO). The stream automatically manages the KVO
/// observation lifecycle and cleans up when the stream is terminated.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// See `NSKeyValueObservingOptions` for available options.
///
/// - Returns: An `AsyncStream<Value>` that yields the current value of the key path
/// whenever it changes.
///
/// ## Example
///
/// ```swift
/// let progress = Progress(totalUnitCount: 100)
///
/// for await fraction in progress.values(for: \.fractionCompleted) {
/// print("Progress: \(fraction.formatted(.percent))")
/// if fraction >= 1.0 { break }
/// }
/// ```
///
/// ## Thread Safety
///
/// The returned stream is thread-safe and can be consumed from any actor context.
/// The KVO observation token is automatically retained by the stream and released
/// when the stream terminates.
///
/// - Important: The observed object must remain alive for the duration of the observation.
/// If the object is deallocated, the stream will terminate.
func values<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = []
@ -25,14 +54,50 @@ public extension NSObjectProtocol where Self: NSObject {
}
}
@available(iOS 18, *)
@available(iOS 18, macOS 15, *)
public extension NSObjectProtocol where Self: NSObject {
/// Observes changes to the specified key path on the object and asynchronously yields each value. Values must be `Sendable`.
/// Observes changes to the specified key path on the object and executes a handler for each change.
///
/// This method combines KVO observation with ``AsyncMonitor`` to provide a convenient way to
/// monitor object property changes. It creates an ``AsyncMonitor`` that observes the key path
/// and preserves the caller's actor isolation context.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// - changeHandler: A closure that's executed with each new value.
/// See `NSKeyValueObservingOptions` for available options.
/// - changeHandler: A closure that's executed with each new value. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncCancellable`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// @MainActor class ProgressView: UIView {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func observeProgress(_ progress: Progress) {
/// // Handler runs on MainActor since caller is @MainActor
/// progress.monitorValues(for: \.fractionCompleted) { [weak self] fraction in
/// self?.updateProgressBar(fraction)
/// }.store(in: &cancellables)
/// }
///
/// func updateProgressBar(_ fraction: Double) {
/// // Update UI safely on MainActor
/// }
/// }
/// ```
///
/// ## Usage with KVO Options
///
/// ```swift
/// object.monitorValues(for: \.property, options: [.initial, .new]) { newValue in
/// print("Property changed to: \(newValue)")
/// }
/// ```
func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [],
@ -44,13 +109,38 @@ public extension NSObjectProtocol where Self: NSObject {
}
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public extension NSObjectProtocol where Self: NSObject {
/// Observes changes to the specified key path on the object and asynchronously yields each value. Values must be `Sendable`.
/// Observes changes to the specified key path on the object and executes a handler for each change (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17. It combines KVO observation with ``AsyncMonitor``
/// and requires a `@Sendable` closure for thread safety.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// - changeHandler: A closure that's executed with each new value.
/// See `NSKeyValueObservingOptions` for available options.
/// - changeHandler: A `@Sendable` closure that's executed with each new value.
///
/// - Returns: An ``AsyncCancellable`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class ProgressObserver {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func observeProgress(_ progress: Progress) {
/// progress.monitorValues(for: \.fractionCompleted) { fraction in
/// print("Progress: \(fraction.formatted(.percent))")
/// }.store(in: &cancellables)
/// }
/// }
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of the non-`@Sendable` version
/// which provides better actor isolation support.
func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [],

View file

@ -2,7 +2,7 @@
import Testing
@MainActor class AsyncCancellableTests {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
@Test func storeInsertsIntoSetAndKeepsSubjectAlive() throws {
var subject: TestCancellable? = TestCancellable()

View file

@ -49,12 +49,12 @@ class AsyncMonitorTests {
try await Task.sleep(for: .milliseconds(10))
}
class Owner {
let deinitHook: () -> Void
final class Owner: Sendable {
let deinitHook: @Sendable () -> Void
private var cancellable: (any AsyncCancellable)?
nonisolated(unsafe) private var cancellable: (any AsyncCancellable)?
init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
init(center: NotificationCenter, deinitHook: @escaping @Sendable () -> Void) {
self.deinitHook = deinitHook
let name = Notification.Name("irrelevant name")
cancellable = center.notifications(named: name)
@ -78,8 +78,10 @@ class AsyncMonitorTests {
}
}
final class SendableObject: NSObject, Sendable {}
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
var context: NSObject? = NSObject()
var context: SendableObject? = SendableObject()
subject = center.notifications(named: name)
.map(\.name)
.monitor(context: context!) { context, receivedName in

View file

@ -9,22 +9,22 @@ class AsyncKVOTests {
@Test(.timeLimit(.minutes(1)))
func monitorValuesYieldsChanges() async throws {
let subject = try #require(subject)
var values = [Double]()
let values = ValueLocker(value: [Double]())
let total = 3
cancellable = subject.values(for: \.fractionCompleted)
.prefix(total)
.monitor { progress in
values.append(progress)
values.modify { $0.append(progress) }
}
for n in 1...total {
subject.completedUnitCount += 1
while values.count < n {
while values.value.count < n {
try await Task.sleep(for: .microseconds(2))
}
}
#expect(values.count == total)
#expect(values.value.count == total)
}
// It's important that the test and the progress-observing task are not on the same actor, so

View file

@ -3,6 +3,8 @@ import Foundation
// MARK: Basics
extension Notification: @unchecked @retroactive Sendable {}
class SimplestVersion {
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
@ -12,8 +14,8 @@ class SimplestVersion {
}
}
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
final class WithContext: Sendable {
nonisolated(unsafe) var cancellables: Set<AnyAsyncCancellable> = []
init() {
NotificationCenter.default
@ -31,10 +33,10 @@ class WithContext {
// MARK: - Combine
import Combine
@preconcurrency import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
@ -42,19 +44,18 @@ class CombineExample {
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}.store(in: &cancellables)
}
}
// MARK: - KVO
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
let progress = Progress(totalUnitCount: 42)
progress.monitorValues(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}