diff --git a/Sources/ScreenStatetKit/Actions/ActionLocker.swift b/Sources/ScreenStatetKit/Actions/ActionLocker.swift index 2385662..98135fb 100644 --- a/Sources/ScreenStatetKit/Actions/ActionLocker.swift +++ b/Sources/ScreenStatetKit/Actions/ActionLocker.swift @@ -5,56 +5,59 @@ // Created by Anthony on 4/12/25. // - import Foundation - public struct ActionLocker { - + /// Use this when the locker is confined to a single actor or execution context. - /// No additional isolation is required as long as it is not accessed concurrently. + /// - Warning: Not thread-safe. For concurrent use, prefer ``ActionLocker/isolated``. public static var nonIsolated: NonIsolatedActionLocker { .init() } - + /// Use this when the locker is shared across multiple actors or concurrent contexts. /// This variant provides the necessary isolation to ensure thread safety. public static var isolated: IsolatedActionLocker { .init() } } -//MARK: - Isolated +// MARK: - Isolated public actor IsolatedActionLocker { - + let locker: NonIsolatedActionLocker - + internal init() { locker = .init() } - + public func lock(_ action: ActionLockable) throws { try locker.lock(action) } - + public func unlock(_ action: ActionLockable) { locker.unlock(action) } - + public func canExecute(_ action: ActionLockable) -> Bool { locker.canExecute(action) } - + public func free() { locker.free() } } -//MARK: - Nonisolated +// MARK: - Nonisolated +/// A non-thread-safe action locker for use within a single concurrency context. +/// +/// - Important: This type has no synchronisation. It must only be accessed +/// from a single actor or serial queue. For use across multiple concurrent +/// contexts, use ``IsolatedActionLocker`` instead. public final class NonIsolatedActionLocker { - + private var actions: [AnyHashable: Bool] - + internal init() { actions = .init() } - + public func lock(_ action: ActionLockable) throws { let isRunning = actions[action.lockKey] ?? false guard !isRunning else { @@ -62,12 +65,12 @@ public final class NonIsolatedActionLocker { } actions.updateValue(true, forKey: action.lockKey) } - + public func unlock(_ action: ActionLockable) { guard actions[action.lockKey] != .none else { return } actions.updateValue(false, forKey: action.lockKey) } - + public func canExecute(_ action: ActionLockable) -> Bool { do { try lock(action) @@ -76,16 +79,15 @@ public final class NonIsolatedActionLocker { return false } } - + public func free() { actions.removeAll() } } extension ActionLocker { - + public enum Errors: Error { case actionIsRunning } } - diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index b12baba..5db4b57 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -5,30 +5,34 @@ // Created by Anthony on 4/12/25. // - - import Foundation public actor CancelBag { private let storage: CancelBagStorage - + public init() { storage = .init() } - + public func cancelAll() { storage.cancelAll() } - + public func cancel(forIdentifier identifier: String) { storage.cancel(forIdentifier: identifier) } - + private func insert(_ canceller: Canceller) { storage.insert(canceller: canceller) } - + + /// Async-safe registration. Prefer this over the Task-based extension when + /// calling from an async context — eliminates the TOCTOU window. + public func store(task: Task, identifier: String = UUID().uuidString) { + insert(Canceller(task, identifier: identifier)) + } + nonisolated fileprivate func append(canceller: Canceller) { Task(priority: .high) { await insert(canceller) @@ -37,41 +41,41 @@ public actor CancelBag { } private final class CancelBagStorage { - + private var cancellers: [String: Canceller] = [:] - + func cancelAll() { let runningTasks = cancellers.values.filter({ !$0.isCancelled }) - runningTasks.forEach{ $0.cancel() } + runningTasks.forEach { $0.cancel() } cancellers.removeAll() } - + func cancel(forIdentifier identifier: String) { guard let task = cancellers[identifier] else { return } task.cancel() cancellers.removeValue(forKey: identifier) } - + func insert(canceller: Canceller) { cancel(forIdentifier: canceller.id) guard !canceller.isCancelled else { return } cancellers.updateValue(canceller, forKey: canceller.id) } - + deinit { cancelAll() } } private struct Canceller: Identifiable, Sendable { - + let cancel: @Sendable () -> Void let id: String var isCancelled: Bool { isCancelledBock() } - + private let isCancelledBock: @Sendable () -> Bool - - init(_ task: Task, identifier: String = UUID().uuidString) { + + init(_ task: Task, identifier: String = UUID().uuidString) { cancel = { task.cancel() } isCancelledBock = { task.isCancelled } id = identifier @@ -79,12 +83,12 @@ private struct Canceller: Identifiable, Sendable { } extension Task { - + public func store(in bag: CancelBag) { let canceller = Canceller(self) bag.append(canceller: canceller) } - + public func store(in bag: CancelBag, withIdentifier identifier: String) { let canceller = Canceller(self, identifier: identifier) bag.append(canceller: canceller) diff --git a/Sources/ScreenStatetKit/States/ScreenState.swift b/Sources/ScreenStatetKit/States/ScreenState.swift index adb395f..3ba9cc2 100644 --- a/Sources/ScreenStatetKit/States/ScreenState.swift +++ b/Sources/ScreenStatetKit/States/ScreenState.swift @@ -2,11 +2,14 @@ import SwiftUI import Combine import Observation -//MARK: - Base Screen States +// MARK: - Base Screen States @MainActor @Observable -open class ScreenState: Sendable { - +// @unchecked Sendable: safe because all mutable state is @MainActor-isolated. +// Subclasses must maintain this invariant — all stored properties must be +// either immutable or @MainActor-isolated. +open class ScreenState: @unchecked Sendable { + public var isLoading: Bool = false { didSet { guard parentStateOption.contains(.loading) else { return } @@ -17,7 +20,7 @@ open class ScreenState: Sendable { } } } - + public var displayError: DisplayableError? { didSet { if let displayError { @@ -30,16 +33,16 @@ open class ScreenState: Sendable { } } } - + private weak var parentState: ScreenState? private let parentStateOption: BindingParentStateOption - + private var loadingTaskCount: Int = 0 { didSet { updateStateLoading() } } - + public init() { parentStateOption = .all } @@ -48,28 +51,28 @@ open class ScreenState: Sendable { parentState = states self.parentStateOption = options } - + public init(states: ScreenState) { parentState = states self.parentStateOption = .all } } -//MARK: - Updaters +// MARK: - Updaters extension ScreenState { - + public struct BindingParentStateOption: OptionSet, Sendable { - + public let rawValue: Int public static let loading = BindingParentStateOption(rawValue: 1 << 0) public static let error = BindingParentStateOption(rawValue: 1 << 1) public static let all: BindingParentStateOption = [.loading, .error] - + public init(rawValue: Int) { self.rawValue = rawValue } } - + private func updateStateLoading() { let loading = loadingTaskCount > 0 if loading != self.isLoading { @@ -78,27 +81,27 @@ extension ScreenState { } } } - + public func showError(_ error: LocalizedError) { withAnimation { self.displayError = .init(message: error.localizedDescription) } } - + public func loadingStarted() { loadingTaskCount += 1 } - + public func loadingFinished() { guard loadingTaskCount > 0 else { return } loadingTaskCount -= 1 } - + public func loadingStarted(action: LoadingTrackable) { guard action.canTrackLoading else { return } loadingStarted() } - + public func loadingFinished(action: LoadingTrackable) { guard action.canTrackLoading else { return } loadingFinished() diff --git a/Sources/ScreenStatetKit/States/StateUpdatable.swift b/Sources/ScreenStatetKit/States/StateUpdatable.swift index c65a80d..cbb7821 100644 --- a/Sources/ScreenStatetKit/States/StateUpdatable.swift +++ b/Sources/ScreenStatetKit/States/StateUpdatable.swift @@ -9,15 +9,14 @@ import SwiftUI @MainActor public protocol StateUpdatable { - + func updateState(withAnimation animation: Animation?, _ updateBlock: @MainActor (_ state: Self) -> Void) } - extension StateUpdatable { - - public func updateState(withAnimation animation: Animation? = .smooth, + + public func updateState(withAnimation animation: Animation? = .none, _ updateBlock: @MainActor (_ state: Self) -> Void) { var transaction = Transaction() transaction.animation = animation diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 896d992..f0cf9c9 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -7,13 +7,18 @@ import Foundation - public protocol ScreenActionStore: TypeNamed, Actor { - + associatedtype AScreenState: ScreenState associatedtype Action: Sendable & ActionLockable - + func binding(state: AScreenState) - + + /// Async dispatch — suspends until the action completes. Cancellable via structured concurrency. + /// Use this in `.task`, `.refreshable`, and any other async context where cancellation matters. + func send(action: Action) async + + /// Fire-and-forget dispatch for sync contexts (button callbacks, `onAppear`, etc.) + /// where you cannot `await`. The spawned task is not cancellable by the caller. nonisolated func receive(action: Action) }