Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions Sources/ScreenStatetKit/Actions/ActionLocker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,69 +5,72 @@
// 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 {
throw ActionLocker.Errors.actionIsRunning
}
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)
Expand All @@ -76,16 +79,15 @@ public final class NonIsolatedActionLocker {
return false
}
}

public func free() {
actions.removeAll()
}
}

extension ActionLocker {

public enum Errors: Error {
case actionIsRunning
}
}

42 changes: 23 additions & 19 deletions Sources/ScreenStatetKit/Helpers/CancelBag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<some Any, some Any>, identifier: String = UUID().uuidString) {
insert(Canceller(task, identifier: identifier))
}

nonisolated fileprivate func append(canceller: Canceller) {
Task(priority: .high) {
await insert(canceller)
Expand All @@ -37,54 +41,54 @@ 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<S,E>(_ task: Task<S,E>, identifier: String = UUID().uuidString) {

init<S, E>(_ task: Task<S, E>, identifier: String = UUID().uuidString) {
cancel = { task.cancel() }
isCancelledBock = { task.isCancelled }
id = identifier
}
}

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)
Expand Down
39 changes: 21 additions & 18 deletions Sources/ScreenStatetKit/States/ScreenState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -17,7 +20,7 @@ open class ScreenState: Sendable {
}
}
}

public var displayError: DisplayableError? {
didSet {
if let displayError {
Expand All @@ -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
}
Expand 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 {
Expand All @@ -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()
Expand Down
7 changes: 3 additions & 4 deletions Sources/ScreenStatetKit/States/StateUpdatable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions Sources/ScreenStatetKit/Store/ScreenActionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}