diff --git a/Sources/ScreenStatetKit/Actions/AsyncAction.swift b/Sources/ScreenStatetKit/Actions/AsyncAction.swift
index bdf0795..dcb534c 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, macOS 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, macOS 26.0, *) {
+ Task.immediate {
+ try await action(Void())
+ }
+ } else {
+ Task {
+ try await action(Void())
+ }
}
}
}
diff --git a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift
index 551f347..49d7c0c 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,28 +59,77 @@ 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) {
- 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)
+ }
}
}
}
+
+//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..5c8ab49 100644
--- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift
+++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift
@@ -5,57 +5,155 @@
// Created by Anthony on 4/12/25.
//
-
-
import Foundation
+import SwiftUI
-public actor CancelBag {
+/// 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: ObservableObject {
private let storage: CancelBagStorage
- public init() {
- storage = .init()
+ public var isEmpty: Bool {
+ storage.isEmpty
}
+ public var count: Int {
+ storage.count
+ }
+
+ public var policy: DuplicatePolicy {
+ storage.duplicatePolicy
+ }
+
+ public init(onDuplicate policy: DuplicatePolicy) {
+ self.storage = .init(onDuplicate: policy)
+ }
+
+ /// Cancels all stored tasks and clears the bag.
public func cancelAll() {
storage.cancelAll()
}
- public func cancel(forIdentifier identifier: String) {
+ @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.
+ public func cancel(forIdentifier identifier: AnyHashable) {
storage.cancel(forIdentifier: identifier)
}
- private func insert(_ canceller: Canceller) {
- storage.insert(canceller: canceller)
+ /// Appends a task to the bag.
+ ///
+ /// This method is nonisolated so tasks can store themselves without
+ /// requiring the caller to `await`.
+ private func insert(_ task: AnyTask) {
+ storage.insert(task: task)
}
- nonisolated fileprivate func append(canceller: Canceller) {
- Task(priority: .high) {
- await insert(canceller)
+ /// This ensures completed tasks do not remain in the bag.
+ /// - Parameter watchId: ``Canceller``'s `watchId`
+ private func removeCanceller(by watchId: UUID) async {
+ storage.remove(by: watchId)
+ }
+
+ nonisolated fileprivate func append(task: AnyTask) {
+ if #available(iOS 26.0, macOS 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)
+ }
}
}
}
+extension CancelBag {
+
+ /// Defines how `CancelBag` handles tasks with the same identifier.
+ public enum DuplicatePolicy: 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: [String: Canceller] = [:]
+ private var runningTasks: [AnyHashable: AnyTask]
+ let duplicatePolicy: CancelBag.DuplicatePolicy
+
+ var isEmpty: Bool {
+ runningTasks.isEmpty
+ }
+
+ var count: Int {
+ runningTasks.count
+ }
+
+ 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: String) {
- guard let task = cancellers[identifier] else { return }
+ func cancel(forIdentifier identifier: AnyHashable) {
+ guard let task = runningTasks[identifier] else { return }
task.cancel()
- cancellers.removeValue(forKey: identifier)
+ runningTasks.removeValue(forKey: identifier)
}
- func insert(canceller: Canceller) {
- cancel(forIdentifier: canceller.id)
- guard !canceller.isCancelled else { return }
- cancellers.updateValue(canceller, forKey: canceller.id)
+ func remove(by watchId: UUID) {
+ guard let key = runningTasks.first(where: { $0.value.watchId == watchId })?.key else { return }
+ runningTasks.removeValue(forKey: key)
+ }
+
+ func insert(task: AnyTask) {
+ guard let existing = runningTasks[task.storageKey] else {
+ _insert(task: task)
+ return
+ }
+ switch duplicatePolicy {
+ case .cancelExisting:
+ existing.cancel()
+ runningTasks.removeValue(forKey: existing.storageKey)
+ _insert(task: task)
+ case .cancelNew:
+ task.cancel()
+ }
+ }
+
+ private func _insert(task: AnyTask) {
+ guard !task.isCancelled else { return }
+ runningTasks.updateValue(task, forKey: task.storageKey)
}
deinit {
@@ -63,30 +161,49 @@ private final class CancelBagStorage {
}
}
-private struct Canceller: Identifiable, Sendable {
+
+//MARK: - AnyTask
+public struct AnyTask: Sendable {
- let cancel: @Sendable () -> Void
- let id: String
- 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: String = UUID().uuidString) {
+ var storageKey: AnyHashable {
+ .init(self.id)
+ }
+
+ init(_ task: Task, identifier: any Identifier) {
cancel = { task.cancel() }
+ waitComplete = { _ = 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)
+ @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: String) {
- let canceller = Canceller(self, identifier: 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/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/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 896d992..97621ec 100644
--- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift
+++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift
@@ -10,10 +10,79 @@ import Foundation
public protocol ScreenActionStore: TypeNamed, Actor {
- associatedtype AScreenState: ScreenState
- associatedtype Action: Sendable & ActionLockable
+ associatedtype ViewState: ScreenState
+ associatedtype Action: Sendable & Hashable
- func binding(state: AScreenState)
+ /// Reference to the view state. Conforming types should store this as `weak`.
+ var viewState: ViewState? { get }
- nonisolated func receive(action: Action)
+ /// Handles an incoming action and performs the corresponding logic.
+ /// - Parameter action: The action to process.
+ /// - Throws: An error if the action handling fails.
+ func receive(action: Action) async throws
+}
+
+extension ScreenActionStore {
+
+ public var viewState: ScreenState? { .none }
+
+ /// Dispatches an action from a non-isolated context.
+ ///
+ /// 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:)`.
+ ///
+ /// 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.
+ /// - bag: Optional `CancelBag` used to manage the lifetime of the created task.
+ ///
+ /// - Returns: The stored `AnyTask`.
+ ///
+ /// - 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
+ ) -> AnyTask
+ where Action: Hashable, Action: LoadingTrackable {
+ if #available(iOS 26.0, macOS 26.0, *) {
+ Task.immediate {
+ await dispatch(action: action)
+ }
+ .store(in: canceller, withIdentifier: action)
+ } else {
+ Task {
+ await dispatch(action: action)
+ }
+ .store(in: canceller, withIdentifier: action)
+ }
+ }
+
+ 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)
+ }
+
+ 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 1b346b2..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()
+ 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()
+ 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()
+ let sut = CancelBag(onDuplicate: .cancelExisting)
let task1 = Task {
try await Task.sleep(for: .seconds(10))
@@ -85,12 +85,56 @@ struct CancelBagTests {
#expect(task1.isCancelled == true)
#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(onDuplicate: .cancelExisting)
+
+ 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(100))
+
+ let count = await sut.count
+ let isEmpty = await sut.isEmpty
+
+ #expect(count == 1)
+ #expect(isEmpty == false)
+ }
}
// MARK: - Helpers
extension CancelBagTests {
private func makeSUT() -> CancelBag {
- CancelBag()
+ 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..5ba840b 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,12 +36,12 @@ class StoreStateIntegrationTests {
await withMainSerialExecutor {
let (state, sut) = await makeSUT()
- let task = Task { await sut.isolatedReceive(action: .slowFetch) }
- await Task.yield()
+ let task = sut.nonisolatedReceive(action: .slowFetch)
+ await Task.megaYield()
#expect(state.isLoading == true)
- await task.value
+ await task.waitComplete()
#expect(state.isLoading == false)
}
@@ -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)