First commit

This commit is contained in:
Sami Samhuri 2025-04-18 13:19:30 -07:00
commit 084aadad84
No known key found for this signature in database
4 changed files with 175 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

28
Package.swift Normal file
View file

@ -0,0 +1,28 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "NotificationTask",
platforms: [
.iOS(.v16),
.macOS(.v12),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "NotificationTask",
targets: ["NotificationTask"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "NotificationTask"),
.testTarget(
name: "NotificationTaskTests",
dependencies: ["NotificationTask"]
),
]
)

View file

@ -0,0 +1,55 @@
public import Foundation
extension Notification: @unchecked @retroactive Sendable {}
/// Manages a task that observes notifications. The tasks's lifetime is tied to the lifetime of the `NotificationTask` instance, so you
/// don't need to explicitly cancel anything. As long as you don't create a reference cycle in the given closure/block then everything will
/// work smoothly.
///
/// When you don't need to worry about reference cycles because the closure is dead simple then just pass in the notification name to
/// ``init(name:center:performing:)`` and then do your work in the closure.
///
/// In other cases you need to be more careful, and there's a second initializer that accepts a context object (typically self) and holds a
/// weak reference to it. Whenever that context object is deallocated then everything stops and is cleaned up automatically. Your closure
/// always receives a strong reference. This one is called ``init(name:context:center:performing:)``.
///
/// ``NotificationTask`` is bound to the main actor and is intended to be used in your view layer. This keeps it simple
@MainActor public final class NotificationTask {
var task: Task<Void, Never>?
init(task: Task<Void, Never>) {
self.task = task
}
public init(
name: Notification.Name,
center: NotificationCenter = .default,
performing block: @escaping (Notification) async -> Void
) {
self.task = Task {
for await notification in center.notifications(named: name) {
await block(notification)
}
}
}
/// Manages the weak reference to your context so you don't leak by mistake.
public init<Context: AnyObject>(
name: Notification.Name,
context: Context,
center: NotificationCenter = .default,
performing block: @escaping (Context, Notification) async -> Void
) {
self.task = Task { [weak context] in
for await notification in center.notifications(named: name) {
guard let context else { break }
await block(context, notification)
}
}
}
deinit {
task?.cancel()
task = nil
}
}

View file

@ -0,0 +1,84 @@
import Foundation
import Testing
@testable import NotificationTask
@MainActor class NotificationTaskTests {
let center = NotificationCenter()
let name = Notification.Name("a random notification")
private var subject: NotificationTask?
@Test func callsBlockWhenNotificationsArePosted() async throws {
await withCheckedContinuation { [center, name] continuation in
subject = NotificationTask(name: name, center: center) { notification in
#expect(notification.name == name)
continuation.resume()
}
Task {
center.post(name: name, object: nil)
}
}
}
@Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws {
subject = NotificationTask(name: name, center: center) { notification in
Issue.record("Called for irrelevant notification \(notification.name)")
}
Task {
center.post(name: Notification.Name("something else"), object: nil)
}
try await Task.sleep(for: .milliseconds(10))
}
@Test func stopsCallingBlockWhenDeallocated() async throws {
subject = NotificationTask(name: name, center: center) { notification in
Issue.record("Called after deallocation")
}
Task {
subject = nil
center.post(name: name, object: nil)
}
try await Task.sleep(for: .milliseconds(10))
}
class Owner {
let deinitHook: () -> Void
private var task: NotificationTask?
@MainActor init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
self.deinitHook = deinitHook
let name = Notification.Name("irrelevant name")
self.task = NotificationTask(name: name, context: self, center: center) { _, _ in }
}
deinit {
deinitHook()
}
}
private var owner: Owner?
@Test(.timeLimit(.minutes(1))) func doesNotCreateReferenceCyclesWithContext() async throws {
await withCheckedContinuation { continuation in
self.owner = Owner(center: center) {
continuation.resume()
}
self.owner = nil
}
}
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
var context: NSObject? = NSObject()
subject = NotificationTask(name: name, context: context!, center: center) { context, notification in
Issue.record("Called after context was deallocated")
}
context = nil
Task {
center.post(name: name, object: nil)
}
try await Task.sleep(for: .milliseconds(10))
}
}