From 14bfddbe4cdf4b00347ace104f3bed277e28d95f Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 8 Jun 2026 17:30:12 +0300 Subject: [PATCH 1/3] Fix test target path so tests actually run The SwiftStateTests test target pointed at `path:"Sources"`, which overlapped the library target's sources and broke `swift build`, running zero tests. Point it at `Tests/SwiftStateTests`. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8c11573..c57f4e8 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,6 @@ let package = Package( .testTarget( name: "SwiftStateTests", dependencies: ["SwiftState"], - path:"Sources"), + path:"Tests/SwiftStateTests"), ] ) From fca98899ba072efc3be4c5b055d6dbd63ce45e17 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 8 Jun 2026 17:30:22 +0300 Subject: [PATCH 2/3] Declare minimum platforms for async/await support Without an explicit `platforms:` floor, SPM assumes an ancient deployment target and `async` code fails with "concurrency is only available in iOS 13.0.0 or newer". Set iOS 13 / macOS 10.15 / tvOS 13 / watchOS 6. --- Package.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Package.swift b/Package.swift index c57f4e8..3f3b5e8 100644 --- a/Package.swift +++ b/Package.swift @@ -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( From 14046278891aa0525084024f3781f20f84bc60f2 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 8 Jun 2026 17:30:31 +0300 Subject: [PATCH 3/3] Add AsyncStateMachine wrapper with async transition handlers AsyncStateMachine wraps a private StateMachine and exposes `async` transition handlers plus `async` tryEvent/tryState that return only after all matching handlers have finished. This lets consumers await async work as part of a transition instead of fire-and-forget `Task {}`. Implemented as a thin wrapper so the existing sync API is unchanged: each async handler is registered on the wrapped machine as a tiny synchronous collector that enqueues the matched (handler, context); the async drive then commits the transition via the wrapped machine (which performs all routing, conditions, ordering and error matching) and awaits the queued handlers in order. No routing logic is duplicated. Conditions and route mappings remain synchronous. AsyncStateMachine is not re-entrancy/concurrency safe; drive it from a single context. --- Sources/AsyncStateMachine.swift | 303 ++++++++++++++++++ .../AsyncStateMachineTests.swift | 160 +++++++++ 2 files changed, 463 insertions(+) create mode 100644 Sources/AsyncStateMachine.swift create mode 100644 Tests/SwiftStateTests/AsyncStateMachineTests.swift diff --git a/Sources/AsyncStateMachine.swift b/Sources/AsyncStateMachine.swift new file mode 100644 index 0000000..330a056 --- /dev/null +++ b/Sources/AsyncStateMachine.swift @@ -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 +{ + /// Closure argument for `Condition` & `AsyncHandler` (shared with `Machine`). + public typealias Context = Machine.Context + + /// Closure for validating a transition (stays synchronous — routing is sync). + public typealias Condition = Machine.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.RouteMapping + + /// Closure-based route for `tryState()`. + public typealias StateRouteMapping = StateMachine.StateRouteMapping + + /// The wrapped synchronous state-machine that owns all routing & state. + private let _machine: StateMachine + + /// 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, 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, 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.Handler + { + return { [weak self] context in + self?._pending.append((handler, context)) + } + } + + //-------------------------------------------------- + // MARK: - Route (event-based) + //-------------------------------------------------- + + @discardableResult + public func addRoutes(event: E, transitions: [Transition], condition: Condition? = nil) -> Disposable + { + return self._machine.addRoutes(event: event, transitions: transitions, condition: condition) + } + + @discardableResult + public func addRoutes(event: Event, transitions: [Transition], condition: Condition? = nil) -> Disposable + { + return self._machine.addRoutes(event: event, transitions: transitions, condition: condition) + } + + @discardableResult + public func addRoutes(event: E, transitions: [Transition], 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, transitions: [Transition], 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, condition: Condition? = nil) -> Disposable + { + return self._machine.addRoute(transition, condition: condition) + } + + @discardableResult + public func addRoute(_ transition: Transition, 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, 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, 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, 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() + } + } +} diff --git a/Tests/SwiftStateTests/AsyncStateMachineTests.swift b/Tests/SwiftStateTests/AsyncStateMachineTests.swift new file mode 100644 index 0000000..d6b07b8 --- /dev/null +++ b/Tests/SwiftStateTests/AsyncStateMachineTests.swift @@ -0,0 +1,160 @@ +// +// AsyncStateMachineTests.swift +// SwiftState +// + +import SwiftState +import XCTest + +class AsyncStateMachineTests: _TestCase +{ + /// `tryEvent()` commits the transition and awaits the async handler before returning. + func testTryEvent_awaitsAsyncHandler() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { _ in + // simulate async work that must finish before `tryEvent` returns + try? await Task.sleep(nanoseconds: 1_000_000) + handlerRan = true + }) + + let success = await machine.tryEvent(.event0) + + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state1) + XCTAssertTrue(handlerRan, "tryEvent must await the async handler to completion") + } + + /// A failed `tryEvent()` invokes the (async) error handler and leaves state unchanged. + func testTryEvent_failure_runsErrorHandler() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoutes(event: .event0, transitions: [.state0 => .state1]) + + var errorRan = false + machine.addErrorHandler { _ in errorRan = true } + + // .event1 has no route + let success = await machine.tryEvent(.event1) + + XCTAssertFalse(success) + XCTAssertEqual(machine.state, .state0) + XCTAssertTrue(errorRan) + } + + /// Handlers run in `order`, and `tryState()` drives state-based transitions. + func testTryState_handlerOrdering() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoute(.state0 => .state1) + + var calls: [Int] = [] + + machine.addHandler(.state0 => .state1, order: 200) { _ in + try? await Task.sleep(nanoseconds: 1_000_000) + calls.append(2) + } + machine.addHandler(.state0 => .state1, order: 100) { _ in + calls.append(1) + } + + let success = await machine.tryState(.state1) + + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state1) + XCTAssertEqual(calls, [1, 2], "handlers must run sequentially in `order`") + } + + /// `addAnyHandler` fires for both event- and state-based transitions. + func testAddAnyHandler() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoute(.any => .any) + machine.addRoutes(event: .event0, transitions: [.any => .any]) + + var count = 0 + machine.addAnyHandler(.any => .any) { _ in count += 1 } + + await machine.tryState(.state1) + await machine.tryEvent(.event0) + + XCTAssertEqual(count, 2) + } + + /// A condition gates the transition (and therefore the handler). + func testCondition() async + { + let machine = AsyncStateMachine(state: .state0) + + var allow = false + var handlerRan = false + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], condition: { _ in allow }, handler: { _ in + handlerRan = true + }) + + let blocked = await machine.tryEvent(.event0) + XCTAssertFalse(blocked) + XCTAssertEqual(machine.state, .state0) + XCTAssertFalse(handlerRan) + + allow = true + let passed = await machine.tryEvent(.event0) + XCTAssertTrue(passed) + XCTAssertEqual(machine.state, .state1) + XCTAssertTrue(handlerRan) + } + + /// Disposing a route/handler removes it. + func testDisposable() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + let disposable = machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { _ in + handlerRan = true + }) + + disposable.dispose() + + let success = await machine.tryEvent(.event0) + XCTAssertFalse(success) + XCTAssertEqual(machine.state, .state0) + XCTAssertFalse(handlerRan) + } + + /// `userInfo` is forwarded through to the handler's `Context`. + func testUserInfoForwarded() async + { + let machine = AsyncStateMachine(state: .state0) + + var received: String? + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { context in + received = context.userInfo as? String + }) + + await machine.tryEvent(.event0, userInfo: "hello") + XCTAssertEqual(received, "hello") + } + + /// `addRouteMapping` resolves the destination dynamically and runs the async handler. + func testRouteMapping() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + machine.addRouteMapping({ event, fromState, _ -> MyState? in + guard event == .event0, fromState == .state0 else { return nil } + return .state2 + }, handler: { _ in + handlerRan = true + }) + + let success = await machine.tryEvent(.event0) + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state2) + XCTAssertTrue(handlerRan) + } +}