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/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