mirror of
https://github.com/samsonjs/NotificationTask.git
synced 2026-03-25 09:15:55 +00:00
First commit
This commit is contained in:
commit
084aadad84
4 changed files with 175 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
28
Package.swift
Normal 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
55
Sources/NotificationTask/NotificationTask.swift
Normal file
55
Sources/NotificationTask/NotificationTask.swift
Normal 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
|
||||
}
|
||||
}
|
||||
84
Tests/NotificationTaskTests/NotificationTaskTests.swift
Normal file
84
Tests/NotificationTaskTests/NotificationTaskTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue