From 084aadad849b4a0571a949429f21be3bf000a3b1 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 18 Apr 2025 13:19:30 -0700 Subject: [PATCH] First commit --- .gitignore | 8 ++ Package.swift | 28 +++++++ .../NotificationTask/NotificationTask.swift | 55 ++++++++++++ .../NotificationTaskTests.swift | 84 +++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/NotificationTask/NotificationTask.swift create mode 100644 Tests/NotificationTaskTests/NotificationTaskTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..31ac4c4 --- /dev/null +++ b/Package.swift @@ -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"] + ), + ] +) diff --git a/Sources/NotificationTask/NotificationTask.swift b/Sources/NotificationTask/NotificationTask.swift new file mode 100644 index 0000000..2cc2e36 --- /dev/null +++ b/Sources/NotificationTask/NotificationTask.swift @@ -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? + + init(task: Task) { + 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( + 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 + } +} diff --git a/Tests/NotificationTaskTests/NotificationTaskTests.swift b/Tests/NotificationTaskTests/NotificationTaskTests.swift new file mode 100644 index 0000000..116b644 --- /dev/null +++ b/Tests/NotificationTaskTests/NotificationTaskTests.swift @@ -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)) + } +}