From 6e5e0fd4e6308fb517b13056e013b211064ac9fc Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Sun, 8 Mar 2026 12:09:58 +0700 Subject: [PATCH 1/9] refactor: improve CancelBag lifecycle, extract StreamStorage, and simplify ScreenActionStore - CancelBag: auto-remove completed tasks via watch(), add isEmpty/count, accept AnyHashable identifiers, make bag parameter optional - StreamProducer: extract StreamStorage class with deinit cleanup, deprecate nonIsolatedFinish() - ScreenActionStore: replace binding(state:) with receive(action:), add nonisolatedReceive helper with CancelBag support - Add CancelBagTests for auto-removal of completed tasks --- .../AsyncStream/StreamProducerType.swift | 45 ++++++++-- .../ScreenStatetKit/Helpers/CancelBag.swift | 85 +++++++++++++++---- .../Helpers/LoadingTrackable.swift | 1 - .../Store/ScreenActionStore.swift | 43 +++++++++- .../Helpers/CancelBagTests.swift | 21 +++++ 5 files changed, 169 insertions(+), 26 deletions(-) diff --git a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift index 551f347..092a83c 100644 --- a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift +++ b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift @@ -23,10 +23,11 @@ public actor StreamProducer: StreamProducerType where Element: Sendable typealias Continuation = AsyncStream.Continuation - public let withLatest: Bool - private var continuations: [String:Continuation] = [:] + private let storage = StreamStorage() private var latestElement: Element? + public let withLatest: Bool + /// Events stream public var stream: AsyncStream { AsyncStream { continuation in @@ -46,12 +47,11 @@ public actor StreamProducer: StreamProducerType where Element: Sendable if withLatest { latestElement = element } - continuations.values.forEach({ $0.yield(element) }) + storage.emit(element: element) } public func finish() { - continuations.values.forEach({ $0.finish() }) - continuations.removeAll() + storage.finish() } private func append(_ continuation: Continuation) { @@ -59,11 +59,11 @@ public actor StreamProducer: StreamProducerType where Element: Sendable continuation.onTermination = {[weak self] _ in self?.onTermination(forKey: key) } - continuations.updateValue(continuation, forKey: key) + storage.update(continuation, forKey: key) } private func removeContinuation(forKey key: String) { - continuations.removeValue(forKey: key) + storage.removeContinuation(forKey: key) } nonisolated private func onTermination(forKey key: String) { @@ -72,6 +72,7 @@ public actor StreamProducer: StreamProducerType where Element: Sendable } } + @available(*, deprecated, renamed: "finish", message: "The Stream will be automatically finished when deallocated. No need to call it manually.") public nonisolated func nonIsolatedFinish() { Task(priority: .high) { await finish() @@ -84,3 +85,33 @@ public actor StreamProducer: StreamProducerType where Element: Sendable } } } + +//MARK: - Storage +extension StreamProducer { + private final class StreamStorage { + + private var continuations: [String:Continuation] = [:] + + func emit(element: Element) { + continuations.values.forEach({ $0.yield(element) }) + } + + func update(_ continuation: Continuation, forKey key: String) { + continuations.updateValue(continuation, forKey: key) + } + + func removeContinuation(forKey key: String) { + continuations.removeValue(forKey: key) + } + + func finish() { + continuations.values.forEach({ $0.finish() }) + continuations.removeAll() + } + + deinit { + finish() + } + } +} + diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index b12baba..eafdd7c 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -5,40 +5,83 @@ // Created by Anthony on 4/12/25. // - - import Foundation +import SwiftUI +/// A container that manages the lifetime of `Task`s. +/// +/// Tasks stored in a ``CancelBag`` can be cancelled individually using an +/// identifier or all at once using ``cancelAll()``. +/// +/// When a task finishes (successfully or with cancellation), it is automatically +/// removed from the bag. +/// +/// If the ``CancelBag`` is tied to the lifetime of a view or object, all stored +/// tasks will be cancelled when the bag is deallocated. public actor CancelBag { private let storage: CancelBagStorage + public var isEmpty: Bool { + storage.isEmpty + } + + public var count: Int { + storage.count + } + public init() { storage = .init() } + /// Cancels all stored tasks and clears the bag. public func cancelAll() { storage.cancelAll() } - public func cancel(forIdentifier identifier: String) { + /// Cancels the task associated with the given identifier. + /// + /// - Parameter identifier: The identifier used when storing the task. + public func cancel(forIdentifier identifier: AnyHashable) { storage.cancel(forIdentifier: identifier) } + /// Appends a canceller to the bag. + /// + /// This method is nonisolated so tasks can store themselves without + /// requiring the caller to `await`. private func insert(_ canceller: Canceller) { storage.insert(canceller: canceller) } + /// Waits for the task to finish and removes it from storage. + /// + /// This ensures completed tasks do not remain in the bag. + private func watch(_ canceller: Canceller) async { + await canceller.waitResult() + storage.remove(by: canceller.watchId) + } + nonisolated fileprivate func append(canceller: Canceller) { - Task(priority: .high) { + Task { await insert(canceller) + await watch(canceller) } } } +//MARK: - Storage private final class CancelBagStorage { - private var cancellers: [String: Canceller] = [:] + private var cancellers: [AnyHashable: Canceller] = [:] + + var isEmpty: Bool { + cancellers.isEmpty + } + + var count: Int { + cancellers.count + } func cancelAll() { let runningTasks = cancellers.values.filter({ !$0.isCancelled }) @@ -46,12 +89,17 @@ private final class CancelBagStorage { cancellers.removeAll() } - func cancel(forIdentifier identifier: String) { + func cancel(forIdentifier identifier: AnyHashable) { guard let task = cancellers[identifier] else { return } task.cancel() cancellers.removeValue(forKey: identifier) } + func remove(by watchId: UUID) { + guard let key = cancellers.first(where: { $0.value.watchId == watchId })?.key else { return } + cancellers.removeValue(forKey: key) + } + func insert(canceller: Canceller) { cancel(forIdentifier: canceller.id) guard !canceller.isCancelled else { return } @@ -63,30 +111,37 @@ private final class CancelBagStorage { } } -private struct Canceller: Identifiable, Sendable { + +//MARK: - Canceller +private struct Canceller { let cancel: @Sendable () -> Void - let id: String + let waitResult: @Sendable () async -> Void + let id: AnyHashable + let watchId: UUID var isCancelled: Bool { isCancelledBock() } private let isCancelledBock: @Sendable () -> Bool - init(_ task: Task, identifier: String = UUID().uuidString) { + init(_ task: Task, identifier: AnyHashable) { cancel = { task.cancel() } + waitResult = { _ = await task.result } isCancelledBock = { task.isCancelled } id = identifier + watchId = .init() } } +//MARK: - Short Path extension Task { - public func store(in bag: CancelBag) { - let canceller = Canceller(self) - bag.append(canceller: canceller) + public func store(in bag: CancelBag?) { + let canceller = Canceller(self, identifier: .init(UUID())) + bag?.append(canceller: canceller) } - public func store(in bag: CancelBag, withIdentifier identifier: String) { - let canceller = Canceller(self, identifier: identifier) - bag.append(canceller: canceller) + public func store(in bag: CancelBag?, withIdentifier identifier: any Hashable) { + let canceller = Canceller(self, identifier: .init(identifier)) + bag?.append(canceller: canceller) } } diff --git a/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift b/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift index e2145e4..31fc1e0 100644 --- a/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift +++ b/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift @@ -5,7 +5,6 @@ // Created by Anthony on 4/12/25. // - import Foundation public protocol LoadingTrackable { diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 896d992..6ce80f7 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -10,10 +10,47 @@ import Foundation public protocol ScreenActionStore: TypeNamed, Actor { - associatedtype AScreenState: ScreenState associatedtype Action: Sendable & ActionLockable - func binding(state: AScreenState) + /// Handles the given action and performs the corresponding logic. + /// + /// - Parameter action: The action to process. + func receive(action: Action) +} + +extension ScreenActionStore { - nonisolated func receive(action: Action) + // Sends an action to the action store from a nonisolated context. + /// + /// This method allows dispatching an `Action` to the actor without requiring + /// the caller to `await`. Internally it creates a `Task` that forwards the + /// action to `receive(action:)`. + /// + /// If a `CancelBag` is provided, the created task will be stored in the bag + /// using the `action` as its identifier. This allows the task to be cancelled + /// later or automatically replaced if another task with the same identifier + /// is stored. + /// + /// - Parameters: + /// - action: The action to send to the receiver. + /// - canceller: An optional `CancelBag` used to manage the lifetime of the + /// created task. If provided, the task will be stored using `action` + /// as its identifier. + /// + /// - Tip: If the ``CancelBag`` is tied to the lifetime of a view, its tasks will be + /// cancelled automatically when the view is destroyed. Otherwise, the tasks + /// are guaranteed to complete before the action store is deallocated. + /// + /// - Note: The `Action` type must conform to `Hashable` so it can be used + /// as a unique identifier for task cancellation. + nonisolated + public func nonisolatedReceive( + action: Action, + canceller: CancelBag? = .none + ) + where Action: Hashable { + Task { + await receive(action: action) + }.store(in: canceller, withIdentifier: action) + } } diff --git a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift index 1b346b2..c73fbb4 100644 --- a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift +++ b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift @@ -85,6 +85,27 @@ struct CancelBagTests { #expect(task1.isCancelled == true) #expect(task2.isCancelled == false) } + + @Test("watch task is copmpleted should remove it from cancelbag storage") + func testWatchTaskCompletedRemoveCancellerFromStorage() async throws { + let sut = CancelBag() + + Task { + try await Task.sleep(for: .milliseconds(10)) + }.store(in: sut) + + Task { + try await Task.sleep(for: .seconds(10)) + }.store(in: sut) + + try await Task.sleep(for: .milliseconds(50)) + + let count = await sut.count + let isEmpty = await sut.isEmpty + + #expect(count == 1) + #expect(isEmpty == false) + } } // MARK: - Helpers From 4ef7013830f297cc630022f82ff2ad75137bd1ae Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 9 Mar 2026 10:04:06 +0700 Subject: [PATCH 2/9] Support async to isolated receive action function. --- Sources/ScreenStatetKit/Store/ScreenActionStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 6ce80f7..4bdf955 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -15,12 +15,12 @@ public protocol ScreenActionStore: TypeNamed, Actor { /// Handles the given action and performs the corresponding logic. /// /// - Parameter action: The action to process. - func receive(action: Action) + func receive(action: Action) async } extension ScreenActionStore { - // Sends an action to the action store from a nonisolated context. + /// `ActionStore` receive an action from a nonisolated context. /// /// This method allows dispatching an `Action` to the actor without requiring /// the caller to `await`. Internally it creates a `Task` that forwards the From 1cedba5a1f9f47bff69d0f8af3f22d22b17ff74b Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 9 Mar 2026 11:43:22 +0700 Subject: [PATCH 3/9] CancelBag should watch task completion weakly, allowing it to be deallocated before completed. --- Sources/ScreenStatetKit/Helpers/CancelBag.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index eafdd7c..9f054e3 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -63,9 +63,9 @@ public actor CancelBag { } nonisolated fileprivate func append(canceller: Canceller) { - Task { - await insert(canceller) - await watch(canceller) + Task {[weak self] in + await self?.insert(canceller) + await self?.watch(canceller) } } } From 5153d1750779876ccbf0efe78f8e32b99e620f84 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 9 Mar 2026 12:15:51 +0700 Subject: [PATCH 4/9] CancelBag should weakly observe task completion so it can be deallocated. --- Sources/ScreenStatetKit/Helpers/CancelBag.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index 9f054e3..1fff644 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -54,18 +54,17 @@ public actor CancelBag { storage.insert(canceller: canceller) } - /// Waits for the task to finish and removes it from storage. - /// /// This ensures completed tasks do not remain in the bag. - private func watch(_ canceller: Canceller) async { - await canceller.waitResult() - storage.remove(by: canceller.watchId) + /// - Parameter watchId: ``Canceller``'s `watchId` + private func removeCanceller(by watchId: UUID) async { + storage.remove(by: watchId) } nonisolated fileprivate func append(canceller: Canceller) { Task {[weak self] in await self?.insert(canceller) - await self?.watch(canceller) + await canceller.waitResult() + await self?.removeCanceller(by: canceller.watchId) } } } From d3a42f8c601be796353ab24f4e5f998089788d6f Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 9 Mar 2026 16:07:08 +0700 Subject: [PATCH 5/9] support CancelBag cancel duplicate task by policy --- .../ScreenStatetKit/Helpers/CancelBag.swift | 52 +++++++++++++++++-- .../Helpers/CancelBagTests.swift | 10 ++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index 1fff644..675249e 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -30,8 +30,12 @@ public actor CancelBag { storage.count } - public init() { - storage = .init() + public var policy: CancelStrategy { + storage.duplicatePolicy + } + + public init(duplicate policy: CancelStrategy) { + self.storage = .init(duplicatePolicy: policy) } /// Cancels all stored tasks and clears the bag. @@ -39,6 +43,13 @@ public actor CancelBag { storage.cancelAll() } + @available(*, deprecated, renamed: "cancelAll", message: "CancelBag will automatically cancel all tasks when deallocated. No need call this method directly.") + nonisolated public func cancelAllInTask() { + Task(priority: .high) { + await cancelAll() + } + } + /// Cancels the task associated with the given identifier. /// /// - Parameter identifier: The identifier used when storing the task. @@ -69,10 +80,24 @@ public actor CancelBag { } } +extension CancelBag { + + /// Defines how `CancelBag` handles tasks with the same identifier. + public enum CancelStrategy: Int8, Sendable { + + //// Cancel the currently executing task if a new task with the same identifier is added. + case cancelExisting + + /// Cancel the newly added task if a task with the same identifier already exists. + case cancelNew + } +} + //MARK: - Storage private final class CancelBagStorage { - private var cancellers: [AnyHashable: Canceller] = [:] + private var cancellers: [AnyHashable: Canceller] + let duplicatePolicy: CancelBag.CancelStrategy var isEmpty: Bool { cancellers.isEmpty @@ -82,6 +107,11 @@ private final class CancelBagStorage { cancellers.count } + init(duplicatePolicy: CancelBag.CancelStrategy) { + self.cancellers = .init() + self.duplicatePolicy = duplicatePolicy + } + func cancelAll() { let runningTasks = cancellers.values.filter({ !$0.isCancelled }) runningTasks.forEach{ $0.cancel() } @@ -100,7 +130,21 @@ private final class CancelBagStorage { } func insert(canceller: Canceller) { - cancel(forIdentifier: canceller.id) + guard let existing = cancellers[canceller.id] else { + _insert(canceller: canceller) + return + } + switch duplicatePolicy { + case .cancelExisting: + existing.cancel() + cancellers.removeValue(forKey: existing.id) + _insert(canceller: canceller) + case .cancelNew: + canceller.cancel() + } + } + + private func _insert(canceller: Canceller) { guard !canceller.isCancelled else { return } cancellers.updateValue(canceller, forKey: canceller.id) } diff --git a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift index c73fbb4..00c7093 100644 --- a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift +++ b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift @@ -13,7 +13,7 @@ struct CancelBagTests { @Test("cancelAll cancels all stored tasks") func test_cancelAll_cancelsAllStoredTasks() async throws { - let sut = CancelBag() + let sut = CancelBag(duplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -39,7 +39,7 @@ struct CancelBagTests { @Test("cancel for identifier cancels specific task") func test_cancelForIdentifier_cancelsSpecificTask() async throws { - let sut = CancelBag() + let sut = CancelBag(duplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -65,7 +65,7 @@ struct CancelBagTests { @Test("store with same identifier cancels previous task") func test_store_withSameIdentifierCancelsPreviousTask() async throws { - let sut = CancelBag() + let sut = CancelBag(duplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -88,7 +88,7 @@ struct CancelBagTests { @Test("watch task is copmpleted should remove it from cancelbag storage") func testWatchTaskCompletedRemoveCancellerFromStorage() async throws { - let sut = CancelBag() + let sut = CancelBag(duplicate: .cancelExisting) Task { try await Task.sleep(for: .milliseconds(10)) @@ -112,6 +112,6 @@ struct CancelBagTests { extension CancelBagTests { private func makeSUT() -> CancelBag { - CancelBag() + CancelBag(duplicate: .cancelExisting) } } From 334f148a0d20d2e4a422a6cce02e852a40617997 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Sun, 15 Mar 2026 13:13:02 +0700 Subject: [PATCH 6/9] Refactor CancelBag, ScreenActionStore, and error handling - Rename Canceller to AnyTask (now public) with discardable store results - Rename CancelStrategy to DuplicatePolicy, init param to onDuplicate - Add NonPresentableError protocol for silent error handling - Enrich DisplayableError with originalError, isSilent, and Error init - Centralize loading/error flow in ScreenActionStore.dispatch - Make ScreenActionStore.receive async throws, add viewState requirement - Update AsyncAction with Task.immediate for iOS 26 and #isolation - Fix typo: ternimateLoadmoreView -> terminateLoadMoreView - Add tests for non-duplicate action locking and silent errors - Remove unused ScreenStatetKitTests.swift --- .../ScreenStatetKit/Actions/AsyncAction.swift | 34 +++-- .../ScreenStatetKit/Helpers/CancelBag.swift | 121 ++++++++++-------- .../Helpers/DisplayableError.swift | 46 ++++++- .../States/LoadmoreScreenState.swift | 2 +- .../Store/ScreenActionStore.swift | 74 ++++++++--- .../Actions/AsyncActionTests.swift | 1 - .../Helpers/CancelBagTests.swift | 35 ++++- .../ScreenStatetKitTests.swift | 6 - .../States/LoadmoreScreenStatesTests.swift | 2 +- .../Store/TestLoadMoreStore.swift | 4 +- .../Store/TestStore.swift | 62 +++++---- .../StoreStateIntegrationTests.swift | 41 ++++-- 12 files changed, 285 insertions(+), 143 deletions(-) delete mode 100644 Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift diff --git a/Sources/ScreenStatetKit/Actions/AsyncAction.swift b/Sources/ScreenStatetKit/Actions/AsyncAction.swift index bdf0795..0704007 100644 --- a/Sources/ScreenStatetKit/Actions/AsyncAction.swift +++ b/Sources/ScreenStatetKit/Actions/AsyncAction.swift @@ -15,17 +15,21 @@ public typealias AsyncActionPut = AsyncAction public struct AsyncAction: Sendable where Input: Sendable, Output: Sendable { - public typealias WorkAction = @Sendable (Input) async throws -> Output + public typealias WorkAction = @Sendable @isolated(any) (Input) async throws -> Output + public let name: String? - private let identifier = UUID().uuidString + private let identifier = UUID() private let action: WorkAction - public init (_ action: @escaping WorkAction) { + public init (name: String? = .none, + _ action: @escaping WorkAction) { + self.name = name self.action = action } @discardableResult - public func asyncExecute(_ input: Input) async throws -> Output { + public func asyncExecute(isolation: isolated (any Actor)? = #isolation, + _ input: Input) async throws -> Output { try await action(input) } } @@ -33,7 +37,7 @@ where Input: Sendable, Output: Sendable { extension AsyncAction where Input == Void { @discardableResult - public func asyncExecute() async throws -> Output { + public func asyncExecute(isolation: isolated (any Actor)? = #isolation) async throws -> Output { try await action(Void()) } } @@ -42,8 +46,14 @@ extension AsyncAction where Input == Void { extension AsyncAction where Output == Void { public func execute(_ input: Input) { - Task { - try await action(input) + if #available(iOS 26.0, *) { + Task.immediate { + try await action(input) + } + } else { + Task { + try await action(input) + } } } } @@ -52,8 +62,14 @@ extension AsyncAction where Output == Void { extension AsyncAction where Output == Void, Input == Void { public func execute() { - Task { - try await action(Void()) + if #available(iOS 26.0, *) { + Task.immediate { + try await action(Void()) + } + } else { + Task { + try await action(Void()) + } } } } diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index 675249e..98ba59c 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -18,7 +18,7 @@ import SwiftUI /// /// If the ``CancelBag`` is tied to the lifetime of a view or object, all stored /// tasks will be cancelled when the bag is deallocated. -public actor CancelBag { +public actor CancelBag: ObservableObject { private let storage: CancelBagStorage @@ -30,12 +30,12 @@ public actor CancelBag { storage.count } - public var policy: CancelStrategy { + public var policy: DuplicatePolicy { storage.duplicatePolicy } - public init(duplicate policy: CancelStrategy) { - self.storage = .init(duplicatePolicy: policy) + public init(onDuplicate policy: DuplicatePolicy) { + self.storage = .init(onDuplicate: policy) } /// Cancels all stored tasks and clears the bag. @@ -57,12 +57,12 @@ public actor CancelBag { storage.cancel(forIdentifier: identifier) } - /// Appends a canceller to the bag. + /// Appends a task to the bag. /// /// This method is nonisolated so tasks can store themselves without /// requiring the caller to `await`. - private func insert(_ canceller: Canceller) { - storage.insert(canceller: canceller) + private func insert(_ task: AnyTask) { + storage.insert(task: task) } /// This ensures completed tasks do not remain in the bag. @@ -71,11 +71,19 @@ public actor CancelBag { storage.remove(by: watchId) } - nonisolated fileprivate func append(canceller: Canceller) { - Task {[weak self] in - await self?.insert(canceller) - await canceller.waitResult() - await self?.removeCanceller(by: canceller.watchId) + nonisolated fileprivate func append(task: AnyTask) { + if #available(iOS 26.0, *) { + Task.immediate {[weak self] in + await self?.insert(task) + await task.waitComplete() + await self?.removeCanceller(by: task.watchId) + } + } else { + Task {[weak self] in + await self?.insert(task) + await task.waitComplete() + await self?.removeCanceller(by: task.watchId) + } } } } @@ -83,7 +91,7 @@ public actor CancelBag { extension CancelBag { /// Defines how `CancelBag` handles tasks with the same identifier. - public enum CancelStrategy: Int8, Sendable { + public enum DuplicatePolicy: Int8, Sendable { //// Cancel the currently executing task if a new task with the same identifier is added. case cancelExisting @@ -96,57 +104,56 @@ extension CancelBag { //MARK: - Storage private final class CancelBagStorage { - private var cancellers: [AnyHashable: Canceller] - let duplicatePolicy: CancelBag.CancelStrategy + private var runningTasks: [AnyHashable: AnyTask] + let duplicatePolicy: CancelBag.DuplicatePolicy var isEmpty: Bool { - cancellers.isEmpty + runningTasks.isEmpty } var count: Int { - cancellers.count + runningTasks.count } - init(duplicatePolicy: CancelBag.CancelStrategy) { - self.cancellers = .init() - self.duplicatePolicy = duplicatePolicy + init(onDuplicate policy: CancelBag.DuplicatePolicy) { + self.runningTasks = .init() + self.duplicatePolicy = policy } func cancelAll() { - let runningTasks = cancellers.values.filter({ !$0.isCancelled }) - runningTasks.forEach{ $0.cancel() } - cancellers.removeAll() + runningTasks.values.forEach{ $0.cancel() } + runningTasks.removeAll() } func cancel(forIdentifier identifier: AnyHashable) { - guard let task = cancellers[identifier] else { return } + guard let task = runningTasks[identifier] else { return } task.cancel() - cancellers.removeValue(forKey: identifier) + runningTasks.removeValue(forKey: identifier) } func remove(by watchId: UUID) { - guard let key = cancellers.first(where: { $0.value.watchId == watchId })?.key else { return } - cancellers.removeValue(forKey: key) + guard let key = runningTasks.first(where: { $0.value.watchId == watchId })?.key else { return } + runningTasks.removeValue(forKey: key) } - func insert(canceller: Canceller) { - guard let existing = cancellers[canceller.id] else { - _insert(canceller: canceller) + func insert(task: AnyTask) { + guard let existing = runningTasks[task.storageKey] else { + _insert(task: task) return } switch duplicatePolicy { case .cancelExisting: existing.cancel() - cancellers.removeValue(forKey: existing.id) - _insert(canceller: canceller) + runningTasks.removeValue(forKey: existing.storageKey) + _insert(task: task) case .cancelNew: - canceller.cancel() + task.cancel() } } - private func _insert(canceller: Canceller) { - guard !canceller.isCancelled else { return } - cancellers.updateValue(canceller, forKey: canceller.id) + private func _insert(task: AnyTask) { + guard !task.isCancelled else { return } + runningTasks.updateValue(task, forKey: task.storageKey) } deinit { @@ -155,20 +162,25 @@ private final class CancelBagStorage { } -//MARK: - Canceller -private struct Canceller { +//MARK: - AnyTask +public struct AnyTask: Sendable { - let cancel: @Sendable () -> Void - let waitResult: @Sendable () async -> Void - let id: AnyHashable - let watchId: UUID - var isCancelled: Bool { isCancelledBock() } + public typealias Identifier = Hashable & Sendable + public let cancel: @Sendable () -> Void + public let waitComplete: @Sendable () async -> Void + public var isCancelled: Bool { isCancelledBock() } + public let id: any Identifier + let watchId: UUID private let isCancelledBock: @Sendable () -> Bool - init(_ task: Task, identifier: AnyHashable) { + var storageKey: AnyHashable { + .init(self.id) + } + + init(_ task: Task, identifier: any Identifier) { cancel = { task.cancel() } - waitResult = { _ = await task.result } + waitComplete = { _ = await task.result } isCancelledBock = { task.isCancelled } id = identifier watchId = .init() @@ -178,13 +190,20 @@ private struct Canceller { //MARK: - Short Path extension Task { - public func store(in bag: CancelBag?) { - let canceller = Canceller(self, identifier: .init(UUID())) - bag?.append(canceller: canceller) + @discardableResult + public func store(in bag: CancelBag?) -> AnyTask { + let anyTask = AnyTask(self, identifier: UUID()) + bag?.append(task: anyTask) + return anyTask } - public func store(in bag: CancelBag?, withIdentifier identifier: any Hashable) { - let canceller = Canceller(self, identifier: .init(identifier)) - bag?.append(canceller: canceller) + @discardableResult + public func store(in bag: CancelBag?, + withIdentifier identifier: Identifier) + -> AnyTask where Identifier: Hashable, Identifier: Sendable { + let anyTask = AnyTask(self, identifier: identifier) + bag?.append(task: anyTask) + return anyTask } } + diff --git a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift index 77a5729..f5cc731 100644 --- a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift +++ b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift @@ -8,17 +8,53 @@ import SwiftUI +public protocol NonPresentableError: Error { + var isSilent: Bool { get } +} + +extension NonPresentableError { + public var isSilent: Bool { true } +} + public struct DisplayableError: LocalizedError, Identifiable, Hashable { - public let id: String + public let id: UUID public var errorDescription: String? { message } - let message: String - - public init(message: String) { + public let message: String + public let originalError: Error? + let isSilent: Bool + + public init(message: String, error: Error? = .none) { self.message = message - self.id = UUID().uuidString + self.id = UUID() + self.isSilent = (error as? NonPresentableError)?.isSilent == true + if let displayable = error as? DisplayableError { + self.originalError = displayable.originalError + } else { + self.originalError = error + } + } + + public init(error: Error) { + self.message = error.localizedDescription + self.id = UUID() + self.isSilent = (error as? NonPresentableError)?.isSilent == true + if let displayable = error as? DisplayableError { + self.originalError = displayable.originalError + } else { + self.originalError = error + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: DisplayableError, rhs: DisplayableError) -> Bool { + lhs.id == rhs.id } } + diff --git a/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift b/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift index 2f3eae4..010f9a9 100644 --- a/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift +++ b/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift @@ -15,7 +15,7 @@ open class LoadmoreScreenState: ScreenState { public private(set) var canShowLoadmore: Bool = false public private(set) var didLoadAllData: Bool = false - public func ternimateLoadmoreView() { + public func terminateLoadMoreView() { withAnimation { self.canShowLoadmore = false } diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 4bdf955..2f6f19d 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -10,19 +10,25 @@ import Foundation public protocol ScreenActionStore: TypeNamed, Actor { - associatedtype Action: Sendable & ActionLockable + associatedtype ViewState: ScreenState + associatedtype Action: Sendable & Hashable - /// Handles the given action and performs the corresponding logic. - /// + /// Reference to the view state. Conforming types should store this as `weak`. + var viewState: ViewState? { get } + + /// Handles an incoming action and performs the corresponding logic. /// - Parameter action: The action to process. - func receive(action: Action) async + /// - Throws: An error if the action handling fails. + func receive(action: Action) async throws } extension ScreenActionStore { - /// `ActionStore` receive an action from a nonisolated context. + public var viewState: ScreenState? { .none } + + /// Dispatches an action from a non-isolated context. /// - /// This method allows dispatching an `Action` to the actor without requiring + /// This method allows sending an `Action` to the actor without requiring /// the caller to `await`. Internally it creates a `Task` that forwards the /// action to `receive(action:)`. /// @@ -33,24 +39,52 @@ extension ScreenActionStore { /// /// - Parameters: /// - action: The action to send to the receiver. - /// - canceller: An optional `CancelBag` used to manage the lifetime of the - /// created task. If provided, the task will be stored using `action` - /// as its identifier. + /// - bag: Optional `CancelBag` used to manage the lifetime of the created task. /// - /// - Tip: If the ``CancelBag`` is tied to the lifetime of a view, its tasks will be - /// cancelled automatically when the view is destroyed. Otherwise, the tasks - /// are guaranteed to complete before the action store is deallocated. + /// - Returns: The stored `AnyTask`. /// - /// - Note: The `Action` type must conform to `Hashable` so it can be used - /// as a unique identifier for task cancellation. - nonisolated + /// - Tip: If the ``CancelBag`` is tied to the lifetime of a view, its tasks + /// will be cancelled automatically when the view is destroyed. + /// + /// - Note: `Action` must conform to `Hashable` so it can be used as an + /// identifier for task cancellation. + @discardableResult nonisolated public func nonisolatedReceive( action: Action, canceller: CancelBag? = .none - ) - where Action: Hashable { - Task { - await receive(action: action) - }.store(in: canceller, withIdentifier: action) + ) -> AnyTask + where Action: Hashable, Action: LoadingTrackable { + if #available(iOS 26.0, *) { + Task.immediate { + await dispatch(action: action) + } + .store(in: canceller, withIdentifier: action) + } else { + Task { + await dispatch(action: action) + } + .store(in: canceller, withIdentifier: action) + } + } + + nonisolated + private func dispatch(action: Action) async + where Action: Hashable, Action: LoadingTrackable { + await viewState?.loadingStarted(action: action) + do { + try await receive(action: action) + } catch let displayable as DisplayableError where !displayable.isSilent { + await viewState?.showError(displayable) + } catch { + printDebug(error.localizedDescription) + } + await viewState?.loadingFinished(action: action) + } + + nonisolated + func printDebug(_ message: @autoclosure () -> String) { + #if DEBUG + print(message()) + #endif } } diff --git a/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift b/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift index 64f2f1f..efb5a1f 100644 --- a/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift +++ b/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift @@ -10,7 +10,6 @@ import Testing struct AsyncActionTests { // MARK: - asyncExecute() Tests - @Test("asyncExecute executes wrapped action and returns output") func test_asyncExecute_executesWrappedActionAndReturnsOutput() async throws { let sut = AsyncActionGet { diff --git a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift index 00c7093..a6e8cae 100644 --- a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift +++ b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift @@ -13,7 +13,7 @@ struct CancelBagTests { @Test("cancelAll cancels all stored tasks") func test_cancelAll_cancelsAllStoredTasks() async throws { - let sut = CancelBag(duplicate: .cancelExisting) + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -39,7 +39,7 @@ struct CancelBagTests { @Test("cancel for identifier cancels specific task") func test_cancelForIdentifier_cancelsSpecificTask() async throws { - let sut = CancelBag(duplicate: .cancelExisting) + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -65,7 +65,7 @@ struct CancelBagTests { @Test("store with same identifier cancels previous task") func test_store_withSameIdentifierCancelsPreviousTask() async throws { - let sut = CancelBag(duplicate: .cancelExisting) + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -86,9 +86,32 @@ struct CancelBagTests { #expect(task2.isCancelled == false) } + @Test("store with same identifier cancels new task") + func test_store_withSameIdentifierCancelsNewTask() async throws { + let sut = CancelBag(onDuplicate: .cancelNew) + + let task1 = Task { + try await Task.sleep(for: .seconds(10)) + } + let task2 = Task { + try await Task.sleep(for: .seconds(10)) + } + + task1.store(in: sut, withIdentifier: "sameId") + + try await Task.sleep(for: .milliseconds(50)) + + task2.store(in: sut, withIdentifier: "sameId") + + try await Task.sleep(for: .milliseconds(50)) + + #expect(task1.isCancelled == false) + #expect(task2.isCancelled == true) + } + @Test("watch task is copmpleted should remove it from cancelbag storage") func testWatchTaskCompletedRemoveCancellerFromStorage() async throws { - let sut = CancelBag(duplicate: .cancelExisting) + let sut = CancelBag(onDuplicate: .cancelExisting) Task { try await Task.sleep(for: .milliseconds(10)) @@ -98,7 +121,7 @@ struct CancelBagTests { try await Task.sleep(for: .seconds(10)) }.store(in: sut) - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(100)) let count = await sut.count let isEmpty = await sut.isEmpty @@ -112,6 +135,6 @@ struct CancelBagTests { extension CancelBagTests { private func makeSUT() -> CancelBag { - CancelBag(duplicate: .cancelExisting) + CancelBag(onDuplicate: .cancelExisting) } } diff --git a/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift b/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift deleted file mode 100644 index f4af68a..0000000 --- a/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import ScreenStateKit - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift b/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift index 0661280..60beb89 100644 --- a/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift +++ b/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift @@ -55,7 +55,7 @@ struct LoadmoreScreenStatesTests { sut.canExecuteLoadmore() // First set it to true #expect(sut.canShowLoadmore == true) - sut.ternimateLoadmoreView() + sut.terminateLoadMoreView() #expect(sut.canShowLoadmore == false) } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift index c647958..105feb0 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift @@ -30,7 +30,7 @@ extension StoreStateIntegrationTests { await state?.updateState { state in state.items = Array(1...10) } - await state?.ternimateLoadmoreView() + await state?.terminateLoadMoreView() case .loadMoreWithPagination(let page): let items = makeItemsForPage(page) @@ -40,7 +40,7 @@ extension StoreStateIntegrationTests { state.currentPage = page state.hasMorePages = hasMore } - await state?.ternimateLoadmoreView() + await state?.terminateLoadMoreView() } actionLocker.unlock(action) diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift index 385c25f..0fe675c 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift @@ -10,34 +10,27 @@ import ScreenStateKit extension StoreStateIntegrationTests { actor TestStore: ScreenActionStore { - private var state: TestScreenState? + private(set) var viewState: TestScreenState? private let actionLocker = ActionLocker.nonIsolated private(set) var fetchCount = 0 func binding(state: TestScreenState) { - self.state = state + self.viewState = state } - nonisolated func receive(action: Action) { - Task { - await isolatedReceive(action: action) - } - } - - func isolatedReceive(action: Action) async { + func receive(action: Action) async throws { guard actionLocker.canExecute(action) else { return } - await state?.loadingStarted(action: action) - + defer { actionLocker.unlock(action) } do { switch action { case .fetchUser(let id): fetchCount += 1 - await state?.updateState { state in + await viewState?.updateState { state in state.userName = "User \(id)" } case .fetchUserProfile: fetchCount += 1 - await state?.updateState { state in + await viewState?.updateState { state in state.userName = "John Doe" state.userAge = 25 state.userEmail = "john@example.com" @@ -47,41 +40,44 @@ extension StoreStateIntegrationTests { case .failingAction: throw TestError.somethingWentWrong + case .faillingWithSilentError: + throw TestError.silentError } } catch { - await state?.showError(DisplayableError(message: error.localizedDescription)) + throw DisplayableError(error: error) } - - actionLocker.unlock(action) - await state?.loadingFinished(action: action) } - enum Action: ActionLockable, LoadingTrackable { + enum Action: ActionLockable, LoadingTrackable, Hashable { case fetchUser(id: Int) case fetchUserProfile(id: Int) case slowFetch case failingAction + case faillingWithSilentError var canTrackLoading: Bool { true } - - var lockKey: AnyHashable { - switch self { - case .fetchUser: - return "fetchUser" - case .fetchUserProfile: - return "fetchUserProfile" - case .slowFetch: - return "slowFetch" - case .failingAction: - return "failingAction" - } - } } - enum TestError: LocalizedError { + enum TestError: LocalizedError, NonPresentableError { case somethingWentWrong + case silentError + var errorDescription: String? { + switch self { + case .somethingWentWrong: + "Something went wrong" + case .silentError: + "The silent error" + } + } - var errorDescription: String? { "Something went wrong" } + var isSilent: Bool { + switch self { + case .somethingWentWrong: + false + case .silentError: + true + } + } } } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift index f8d447d..492f595 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift @@ -7,6 +7,7 @@ import Testing import ConcurrencyExtras import ScreenStateKit +import Observation class StoreStateIntegrationTests { private var leakTrackers: [MemoryLeakTracker] = [] @@ -21,8 +22,7 @@ class StoreStateIntegrationTests { @MainActor func test_receiveAction_updatesStateViaKeyPath() async throws { let (state, sut) = await makeSUT() - - await sut.isolatedReceive(action: .fetchUser(id: 123)) + try await sut.receive(action: .fetchUser(id: 123)) #expect(state.userName == "User 123") #expect(state.isLoading == false) @@ -36,8 +36,8 @@ class StoreStateIntegrationTests { await withMainSerialExecutor { let (state, sut) = await makeSUT() - let task = Task { await sut.isolatedReceive(action: .slowFetch) } - await Task.yield() + let task = Task { await sut.nonisolatedReceive(action: .slowFetch).waitComplete() } + await Task.megaYield() #expect(state.isLoading == true) @@ -53,8 +53,8 @@ class StoreStateIntegrationTests { await withMainSerialExecutor { let (state, sut) = await makeSUT() - sut.receive(action: .fetchUser(id: 1)) - sut.receive(action: .fetchUser(id: 2)) + sut.nonisolatedReceive(action: .fetchUser(id: 1)) + sut.nonisolatedReceive(action: .fetchUser(id: 1)) await Task.megaYield() @@ -62,6 +62,20 @@ class StoreStateIntegrationTests { #expect(await sut.fetchCount == 1) } } + + @Test("action locker dont prevents duplicate action execution when params is differents") + @MainActor + func test_actionLocker_dontPreventsDuplicateExecution() async throws { + await withMainSerialExecutor { + let (_, sut) = await makeSUT() + + sut.nonisolatedReceive(action: .fetchUser(id: 1)) + sut.nonisolatedReceive(action: .fetchUser(id: 2)) + + await Task.megaYield() + #expect(await sut.fetchCount == 2) + } + } // MARK: - Error Handling Tests @@ -70,11 +84,22 @@ class StoreStateIntegrationTests { func test_errorAction_setsDisplayError() async throws { let (state, sut) = await makeSUT() - await sut.isolatedReceive(action: .failingAction) + await sut.nonisolatedReceive(action: .failingAction).waitComplete() #expect(state.displayError?.errorDescription == "Something went wrong") #expect(state.isLoading == false) } + + @Test("error action sets nonDisplayError on state") + @MainActor + func test_errorAction_setsNonDisplayError() async throws { + let (state, sut) = await makeSUT() + + await sut.nonisolatedReceive(action: .faillingWithSilentError).waitComplete() + + #expect(state.displayError == nil) + #expect(state.isLoading == false) + } // MARK: - LoadmoreScreenStates Integration Tests @@ -100,7 +125,7 @@ class StoreStateIntegrationTests { func test_fetchUserProfile_updatesMultipleProperties() async throws { let (state, sut) = await makeSUT() - await sut.isolatedReceive(action: .fetchUserProfile(id: 42)) + try await sut.receive(action: .fetchUserProfile(id: 42)) #expect(state.userName == "John Doe") #expect(state.userAge == 25) From d0b00108f1431e650624f914a69bc2ca147a40e0 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Sun, 15 Mar 2026 13:26:51 +0700 Subject: [PATCH 7/9] Add macOS 26.0 availability to Task.immediate usage --- Sources/ScreenStatetKit/Actions/AsyncAction.swift | 4 ++-- Sources/ScreenStatetKit/Helpers/CancelBag.swift | 2 +- Sources/ScreenStatetKit/Store/ScreenActionStore.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ScreenStatetKit/Actions/AsyncAction.swift b/Sources/ScreenStatetKit/Actions/AsyncAction.swift index 0704007..dcb534c 100644 --- a/Sources/ScreenStatetKit/Actions/AsyncAction.swift +++ b/Sources/ScreenStatetKit/Actions/AsyncAction.swift @@ -46,7 +46,7 @@ extension AsyncAction where Input == Void { extension AsyncAction where Output == Void { public func execute(_ input: Input) { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { Task.immediate { try await action(input) } @@ -62,7 +62,7 @@ extension AsyncAction where Output == Void { extension AsyncAction where Output == Void, Input == Void { public func execute() { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { Task.immediate { try await action(Void()) } diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index 98ba59c..5c8ab49 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -72,7 +72,7 @@ public actor CancelBag: ObservableObject { } nonisolated fileprivate func append(task: AnyTask) { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { Task.immediate {[weak self] in await self?.insert(task) await task.waitComplete() diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 2f6f19d..523f49c 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -54,7 +54,7 @@ extension ScreenActionStore { canceller: CancelBag? = .none ) -> AnyTask where Action: Hashable, Action: LoadingTrackable { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { Task.immediate { await dispatch(action: action) } From af42d75c534189672f8566cbe77e4ffe8d83a899 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Sun, 15 Mar 2026 17:58:31 +0700 Subject: [PATCH 8/9] Clean code --- Sources/ScreenStatetKit/Store/ScreenActionStore.swift | 4 +--- .../StoreStateIntegrationTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 523f49c..97621ec 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -66,8 +66,7 @@ extension ScreenActionStore { .store(in: canceller, withIdentifier: action) } } - - nonisolated + private func dispatch(action: Action) async where Action: Hashable, Action: LoadingTrackable { await viewState?.loadingStarted(action: action) @@ -81,7 +80,6 @@ extension ScreenActionStore { await viewState?.loadingFinished(action: action) } - nonisolated func printDebug(_ message: @autoclosure () -> String) { #if DEBUG print(message()) diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift index 492f595..5ba840b 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift @@ -36,12 +36,12 @@ class StoreStateIntegrationTests { await withMainSerialExecutor { let (state, sut) = await makeSUT() - let task = Task { await sut.nonisolatedReceive(action: .slowFetch).waitComplete() } + let task = sut.nonisolatedReceive(action: .slowFetch) await Task.megaYield() #expect(state.isLoading == true) - await task.value + await task.waitComplete() #expect(state.isLoading == false) } From dbf25f9fb20bfaf97545141f1e73a0518320a122 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Sun, 15 Mar 2026 18:15:39 +0700 Subject: [PATCH 9/9] refactor Task.immediate to StreamProducer --- .../AsyncStream/StreamProducerType.swift | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift index 092a83c..49d7c0c 100644 --- a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift +++ b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift @@ -67,21 +67,39 @@ public actor StreamProducer: StreamProducerType where Element: Sendable } nonisolated private func onTermination(forKey key: String) { - Task(priority: .high) { - await removeContinuation(forKey: key) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await removeContinuation(forKey: key) + } + } else { + Task(priority: .high) { + await removeContinuation(forKey: key) + } } } @available(*, deprecated, renamed: "finish", message: "The Stream will be automatically finished when deallocated. No need to call it manually.") public nonisolated func nonIsolatedFinish() { - Task(priority: .high) { - await finish() + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await finish() + } + } else { + Task(priority: .high) { + await finish() + } } } public nonisolated func nonIsolatedEmit(_ element: Element) { - Task(priority: .high) { - await emit(element: element) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await emit(element: element) + } + } else { + Task(priority: .high) { + await emit(element: element) + } } } }