diff --git a/Sources/ScreenStatetKit/Actions/ActionLocker.swift b/Sources/ScreenStatetKit/Actions/ActionLocker.swift index e5d05c8..2385662 100644 --- a/Sources/ScreenStatetKit/Actions/ActionLocker.swift +++ b/Sources/ScreenStatetKit/Actions/ActionLocker.swift @@ -8,18 +8,57 @@ import Foundation -public actor ActionLocker { + +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. + 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 +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 +public final class NonIsolatedActionLocker { private var actions: [AnyHashable: Bool] - public init() { + internal init() { actions = .init() } public func lock(_ action: ActionLockable) throws { let isRunning = actions[action.lockKey] ?? false guard !isRunning else { - throw Errors.actionIsRunning + throw ActionLocker.Errors.actionIsRunning } actions.updateValue(true, forKey: action.lockKey) } diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index c741840..b12baba 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -11,40 +11,55 @@ import Foundation public actor CancelBag { - private var cancellers: [String:Canceller] + private let storage: CancelBagStorage public init() { - cancellers = .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) + } + + nonisolated fileprivate func append(canceller: Canceller) { + Task(priority: .high) { + await insert(canceller) + } + } +} + +private final class CancelBagStorage { + + private var cancellers: [String: Canceller] = [:] + + func cancelAll() { let runningTasks = cancellers.values.filter({ !$0.isCancelled }) runningTasks.forEach{ $0.cancel() } cancellers.removeAll() } - public func cancel(forIdentifier identifier: String) { + func cancel(forIdentifier identifier: String) { guard let task = cancellers[identifier] else { return } task.cancel() cancellers.removeValue(forKey: identifier) } - nonisolated public func cancelAllInTask() { - Task(priority: .high) { - await cancelAll() - } - } - - private func store(_ canceller: Canceller) { + func insert(canceller: Canceller) { cancel(forIdentifier: canceller.id) guard !canceller.isCancelled else { return } cancellers.updateValue(canceller, forKey: canceller.id) } - nonisolated fileprivate func append(canceller: Canceller) { - Task(priority: .high) { - await store(canceller) - } + deinit { + cancelAll() } } diff --git a/Sources/ScreenStatetKit/States/StateUpdatable.swift b/Sources/ScreenStatetKit/States/StateUpdatable.swift index d006c4d..c65a80d 100644 --- a/Sources/ScreenStatetKit/States/StateUpdatable.swift +++ b/Sources/ScreenStatetKit/States/StateUpdatable.swift @@ -10,20 +10,18 @@ import SwiftUI @MainActor public protocol StateUpdatable { - func updateState( _ updateBlock: @MainActor (_ state: Self) -> Void, - withAnimation animation: Animation?, - disablesAnimations: Bool) + func updateState(withAnimation animation: Animation?, + _ updateBlock: @MainActor (_ state: Self) -> Void) } extension StateUpdatable { - public func updateState( _ updateBlock: @MainActor (_ state: Self) -> Void, - withAnimation animation: Animation? = .none, - disablesAnimations: Bool = false) { + public func updateState(withAnimation animation: Animation? = .smooth, + _ updateBlock: @MainActor (_ state: Self) -> Void) { var transaction = Transaction() transaction.animation = animation - transaction.disablesAnimations = disablesAnimations + transaction.disablesAnimations = animation == .none withTransaction(transaction) { updateBlock(self) } diff --git a/Tests/ScreenStatetKitTests/Actions/ActionLockerTests.swift b/Tests/ScreenStatetKitTests/Actions/ActionLockerTests.swift index 68a529c..68030a0 100644 --- a/Tests/ScreenStatetKitTests/Actions/ActionLockerTests.swift +++ b/Tests/ScreenStatetKitTests/Actions/ActionLockerTests.swift @@ -95,7 +95,7 @@ extension ActionLockerTests { case loadMore } - private func makeSUT() -> ActionLocker { - ActionLocker() + private func makeSUT() -> IsolatedActionLocker { + ActionLocker.isolated } } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift index 6d2373e..c647958 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift @@ -11,7 +11,7 @@ import ScreenStateKit extension StoreStateIntegrationTests { actor TestLoadmoreStore: ScreenActionStore { private var state: TestLoadmoreState? - private let actionLocker = ActionLocker() + private let actionLocker = ActionLocker.nonIsolated func binding(state: TestLoadmoreState) { self.state = state @@ -22,7 +22,7 @@ extension StoreStateIntegrationTests { } func isolatedReceive(action: Action) async { - guard await actionLocker.canExecute(action) else { return } + guard actionLocker.canExecute(action) else { return } await state?.loadingStarted(action: action) switch action { @@ -43,7 +43,7 @@ extension StoreStateIntegrationTests { await state?.ternimateLoadmoreView() } - await actionLocker.unlock(action) + actionLocker.unlock(action) await state?.loadingFinished(action: action) } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift index d8334fd..b0b99c7 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift @@ -11,7 +11,7 @@ extension StoreStateIntegrationTests { actor TestStore: ScreenActionStore { private var state: TestScreenState? - private let actionLocker = ActionLocker() + private let actionLocker = ActionLocker.nonIsolated private(set) var fetchCount = 0 func binding(state: TestScreenState) { @@ -25,7 +25,7 @@ extension StoreStateIntegrationTests { } func isolatedReceive(action: Action) async { - guard await actionLocker.canExecute(action) else { return } + guard actionLocker.canExecute(action) else { return } await state?.loadingStarted(action: action) do { @@ -52,7 +52,7 @@ extension StoreStateIntegrationTests { await state?.showError(RMDisplayableError(message: error.localizedDescription)) } - await actionLocker.unlock(action) + actionLocker.unlock(action) await state?.loadingFinished(action: action) }