Skip to content
Open
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
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import PackageDescription

let package = Package(
name: "SwiftState",
platforms: [
.iOS(.v13),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v6)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
Expand All @@ -25,6 +31,6 @@ let package = Package(
.testTarget(
name: "SwiftStateTests",
dependencies: ["SwiftState"],
path:"Sources"),
path:"Tests/SwiftStateTests"),
]
)
303 changes: 303 additions & 0 deletions Sources/AsyncStateMachine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
//
// AsyncStateMachine.swift
// SwiftState
//
// Async/await-friendly wrapper around `StateMachine`.
//

///
/// An async/await-friendly wrapper around `StateMachine`.
///
/// `AsyncStateMachine` mirrors the `StateMachine` API but lets transition handlers be
/// `async`, and exposes `async` `tryEvent()` / `tryState()` that only return **after** all
/// matching handlers have finished. This is useful when a transition needs to `await` work
/// (navigation, networking, …) before the caller continues.
///
/// It is a thin wrapper: all routing, conditions, ordering and error matching are delegated
/// to an internal `StateMachine`. Async handlers are registered on the wrapped machine as
/// lightweight synchronous collectors that enqueue the matched `async` work; the wrapper then
/// commits the transition synchronously (state is updated by the wrapped machine, exactly as
/// in `StateMachine`) and `await`s the enqueued handlers in order.
///
/// - Note: This type is **not** re-entrancy/concurrency safe. Drive it from a single
/// concurrency context (e.g. a `@MainActor` flow coordinator) and avoid overlapping
/// `tryEvent()` / `tryState()` calls.
///
public final class AsyncStateMachine<S: StateType, E: EventType>
{
/// Closure argument for `Condition` & `AsyncHandler` (shared with `Machine`).
public typealias Context = Machine<S, E>.Context

/// Closure for validating a transition (stays synchronous — routing is sync).
public typealias Condition = Machine<S, E>.Condition

/// Transition callback invoked, and `await`ed, when state has changed successfully.
public typealias AsyncHandler = (Context) async -> ()

/// Closure-based route for `tryEvent()`.
public typealias RouteMapping = Machine<S, E>.RouteMapping

/// Closure-based route for `tryState()`.
public typealias StateRouteMapping = StateMachine<S, E>.StateRouteMapping

/// The wrapped synchronous state-machine that owns all routing & state.
private let _machine: StateMachine<S, E>

/// Async handlers matched (in `order`) by the most recent sync drive, awaiting execution.
private var _pending: [(handler: AsyncHandler, context: Context)] = []

//--------------------------------------------------
// MARK: - Init
//--------------------------------------------------

public init(state: S, initClosure: ((AsyncStateMachine) -> ())? = nil)
{
self._machine = StateMachine(state: state)
initClosure?(self)
}

public func configure(_ closure: (AsyncStateMachine) -> ())
{
closure(self)
}

public var state: S
{
return self._machine.state
}

//--------------------------------------------------
// MARK: - hasRoute / canTry
//--------------------------------------------------

public func hasRoute(event: E, transition: Transition<S>, userInfo: Any? = nil) -> Bool
{
return self._machine.hasRoute(event: event, transition: transition, userInfo: userInfo)
}

public func hasRoute(event: E, fromState: S, toState: S, userInfo: Any? = nil) -> Bool
{
return self._machine.hasRoute(event: event, fromState: fromState, toState: toState, userInfo: userInfo)
}

public func hasRoute(_ transition: Transition<S>, userInfo: Any? = nil) -> Bool
{
return self._machine.hasRoute(transition, userInfo: userInfo)
}

public func hasRoute(fromState: S, toState: S, userInfo: Any? = nil) -> Bool
{
return self._machine.hasRoute(fromState: fromState, toState: toState, userInfo: userInfo)
}

/// - Returns: Preferred-`toState`.
public func canTryEvent(_ event: E, userInfo: Any? = nil) -> S?
{
return self._machine.canTryEvent(event, userInfo: userInfo)
}

public func canTryState(_ toState: S, userInfo: Any? = nil) -> Bool
{
return self._machine.canTryState(toState, userInfo: userInfo)
}

//--------------------------------------------------
// MARK: - tryEvent / tryState
//--------------------------------------------------

/// Drive an event-based transition, `await`ing all matching handlers (or error handlers).
@discardableResult
public func tryEvent(_ event: E, userInfo: Any? = nil) async -> Bool
{
let success = self._machine.tryEvent(event, userInfo: userInfo)
await self._drainPending()
return success
}

/// Drive a state-based transition, `await`ing all matching handlers (or error handlers).
@discardableResult
public func tryState(_ toState: S, userInfo: Any? = nil) async -> Bool
{
let success = self._machine.tryState(toState, userInfo: userInfo)
await self._drainPending()
return success
}

/// Run, in `order`, the handlers the wrapped machine matched during the last sync drive.
private func _drainPending() async
{
let pending = self._pending
self._pending = []

for entry in pending {
await entry.handler(entry.context)
}
}

/// Wrap an `async` handler as a sync collector that enqueues onto `_pending` when matched.
private func _collector(_ handler: @escaping AsyncHandler) -> Machine<S, E>.Handler
{
return { [weak self] context in
self?._pending.append((handler, context))
}
}

//--------------------------------------------------
// MARK: - Route (event-based)
//--------------------------------------------------

@discardableResult
public func addRoutes(event: E, transitions: [Transition<S>], condition: Condition? = nil) -> Disposable
{
return self._machine.addRoutes(event: event, transitions: transitions, condition: condition)
}

@discardableResult
public func addRoutes(event: Event<E>, transitions: [Transition<S>], condition: Condition? = nil) -> Disposable
{
return self._machine.addRoutes(event: event, transitions: transitions, condition: condition)
}

@discardableResult
public func addRoutes(event: E, transitions: [Transition<S>], condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable
{
return self.addRoutes(event: .some(event), transitions: transitions, condition: condition, handler: handler)
}

@discardableResult
public func addRoutes(event: Event<E>, transitions: [Transition<S>], condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable
{
let routeDisposable = self._machine.addRoutes(event: event, transitions: transitions, condition: condition)
let handlerDisposable = self.addHandler(event: event, handler: handler)

return ActionDisposable {
routeDisposable.dispose()
handlerDisposable.dispose()
}
}

//--------------------------------------------------
// MARK: - Route (state-based)
//--------------------------------------------------

@discardableResult
public func addRoute(_ transition: Transition<S>, condition: Condition? = nil) -> Disposable
{
return self._machine.addRoute(transition, condition: condition)
}

@discardableResult
public func addRoute(_ transition: Transition<S>, condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable
{
let routeDisposable = self._machine.addRoute(transition, condition: condition)

// Re-check the condition in the handler: the transition-keyed handler can match via
// `.any` wildcards, so it must be gated by this route's own condition (mirrors `StateMachine`).
let handlerDisposable = self._machine.addHandler(transition) { [weak self] context in
if _canPassCondition(condition, forEvent: nil, fromState: context.fromState, toState: context.toState, userInfo: context.userInfo) {
self?._pending.append((handler, context))
}
}

return ActionDisposable {
routeDisposable.dispose()
handlerDisposable.dispose()
}
}

//--------------------------------------------------
// MARK: - Handler
//--------------------------------------------------

/// Add an `async` handler invoked when `tryEvent()` succeeds for `event`.
@discardableResult
public func addHandler(event: E, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
return self.addHandler(event: .some(event), order: order, handler: handler)
}

@discardableResult
public func addHandler(event: Event<E>, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
return self._machine.addHandler(event: event, order: order, handler: self._collector(handler))
}

/// Add an `async` handler invoked when `tryState()` succeeds for `transition`.
/// - Note: This handler will not be invoked for `tryEvent()`.
@discardableResult
public func addHandler(_ transition: Transition<S>, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
return self._machine.addHandler(transition, order: order, handler: self._collector(handler))
}

/// Add an `async` handler invoked when either `tryEvent()` or `tryState()` succeeds for `transition`.
@discardableResult
public func addAnyHandler(_ transition: Transition<S>, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
return self._machine.addAnyHandler(transition, order: order, handler: self._collector(handler))
}

/// Add an `async` handler invoked when `tryEvent()` / `tryState()` fails.
@discardableResult
public func addErrorHandler(order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
return self._machine.addErrorHandler(order: order, handler: self._collector(handler))
}

//--------------------------------------------------
// MARK: - RouteMapping
//--------------------------------------------------

@discardableResult
public func addRouteMapping(_ routeMapping: @escaping RouteMapping) -> Disposable
{
return self._machine.addRouteMapping(routeMapping)
}

@discardableResult
public func addRouteMapping(_ routeMapping: @escaping RouteMapping, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable
{
let routeDisposable = self._machine.addRouteMapping(routeMapping)

let handlerDisposable = self._machine.addHandler(event: .any, order: order) { [weak self] context in
guard let preferredToState = routeMapping(context.event, context.fromState, context.userInfo),
preferredToState == context.toState else
{
return
}
self?._pending.append((handler, context))
}

return ActionDisposable {
routeDisposable.dispose()
handlerDisposable.dispose()
}
}

@discardableResult
public func addStateRouteMapping(_ routeMapping: @escaping StateRouteMapping) -> Disposable
{
return self._machine.addStateRouteMapping(routeMapping)
}

@discardableResult
public func addStateRouteMapping(_ routeMapping: @escaping StateRouteMapping, handler: @escaping AsyncHandler) -> Disposable
{
let routeDisposable = self._machine.addStateRouteMapping(routeMapping)

let handlerDisposable = self._machine.addHandler(.any => .any) { [weak self] context in
guard context.event == nil else { return }
guard let preferredToStates = routeMapping(context.fromState, context.userInfo),
preferredToStates.contains(context.toState) else
{
return
}
self?._pending.append((handler, context))
}

return ActionDisposable {
routeDisposable.dispose()
handlerDisposable.dispose()
}
}
}
Loading