Skip to content
Merged
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
85 changes: 85 additions & 0 deletions Sources/EffectComponents/EffectActor/ActorStateBinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@

/// A lightweight helper that provides safe, isolated access to mutable state owned by an actor.
///
/// ActorStateBinding encapsulates a pair of closures that read and write a value
/// from within a specific actor’s isolation domain. It allows callers that are
/// already isolated to the host actor to:
/// - Read the current state value synchronously.
/// - Perform an in-place mutation of the value via an async closure, ensuring the
/// updated value is written back to the actor when the operation completes.
///
/// This type is useful when you want to expose a specific piece of actor state
/// to internal utilities or helpers without revealing the entire actor, while still
/// respecting Swift’s actor isolation guarantees.
///
/// Type Parameters:
/// - Host: The actor type that owns the state.
/// - Value: The type of the state value being accessed and mutated.
///
/// Initialization:
/// - get: A closure that reads the value from the isolated actor.
/// - set: A closure that writes a new value to the isolated actor.
///
/// Thread-safety and Isolation:
/// - Calls to `state(systemActor:)` and `withValue(systemActor:body:)` must be made
/// from within the host actor’s isolation (e.g., methods on the actor or `await`
/// calls that hop into the actor). This preserves data-race freedom.
///
/// Performance:
/// - `withValue` copies the value out and writes it back once. For large Value types,
/// consider using reference semantics or splitting state to reduce copying.
///
/// See Also:
/// - Swift Concurrency, Actor isolation, `isolated` parameters, and `nonisolated(nonsending)` closures.
public struct ActorStateBinding<Host: Actor, Value> {
private let _get: (isolated Host) -> Value
private let _set: (isolated Host, Value) -> Void

/// Initializes an ActorStateBinding with custom get and set closures that operate
/// under the host actor’s isolation.
///
/// - Parameters:
/// - get: A closure that reads and returns the state value from an isolated host.
/// - set: A closure that writes a new state value to an isolated host.
///
///
/// Returns the current value of the bound state while the caller is isolated to the host actor.
///
/// - Parameter systemActor: The isolated host actor, usually passed implicitly as `#isolation`
/// from within actor-isolated contexts. Defaults to `#isolation`.
/// - Returns: The current state value.
///
///
/// Reads the current value, allows in-place mutation in the provided body, and writes
/// the updated value back to the host actor when the body completes.
///
/// The body runs nonisolated with an inout copy of the value. Upon return (including
/// when throwing), the possibly-mutated value is written back to the actor.
///
/// - Parameters:
/// - systemActor: The isolated host actor.
/// - body: An async throwing closure that receives an inout Value to mutate.
/// - Returns: The value returned by `body`.
/// - Throws: Rethrows any error thrown by `body`.
/// - Note: Use this for compound updates to keep get/set paired and scoped to the operation.
public init(
get: @escaping (isolated Host) -> Value,
set: @escaping (isolated Host, Value) -> Void
) {
self._get = get
self._set = set
}

public func state(systemActor: isolated Host = #isolation) -> Value {
_get(systemActor)
}

// Require the host to be isolated at the call site
public func withValue<R>(
systemActor: isolated Host, body: nonisolated(nonsending) (inout Value) async throws -> R
) async rethrows -> R where R: Sendable {
var s = _get(systemActor)
defer { _set(systemActor, s) }
return try await body(&s)
}
}
142 changes: 142 additions & 0 deletions Sources/EffectComponents/EffectActor/EffectActor.Input.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
@available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *)
extension EffectActor {

/// `Sendable` input handle for dispatching events into an ``EffectActor``.
///
/// `EffectActor.Input` exposes the same runtime entry points as the host
/// actor while keeping only a weak reference to that actor:
///
/// - ``send(_:)`` dispatches immediately and awaits the synchronous
/// reduction path.
/// - ``post(_:)`` schedules fire-and-forget dispatch.
/// - ``request(_:)`` suspends until the triggered effect chain settles.
///
/// ### Isolation and lifetime safety
///
/// All state mutation still runs on the owning ``EffectActor``. The input
/// handle itself is safe to move across isolation domains because it only
/// stores a weak actor reference. If that actor has already been released,
/// operations fail with ``RuntimeError/actorDeallocated``.
///
/// If the calling `Task` is cancelled while awaiting ``request(_:)``, the
/// suspension continues until the accepted effect chain reaches a terminal
/// outcome.
///
/// ### Generic parameters
///
/// - `Event`: The event type dispatched into the state machine.
/// - `Output`: The value returned by ``request(_:)``.
/// Use `Void` when no return value is needed.
public struct Input: TransducerInput<T.Event, T.Output>, Sendable {

init(_ actor: EffectActor) {
self.actor = actor
}

private weak let actor: EffectActor?

/// Dispatches `event` synchronously on the Actor.
///
/// Use `send` when you are already running on the Actor and want the event to be
/// processed immediately, in the same synchronous turn. A typical example is a SwiftUI
/// button action:
///
/// ```swift
/// Button("Increment") {
/// // Processed before the next await point:
/// input.send(.increment)
/// }
/// ```
///
/// "Synchronous" here means that `update` is called inline, any `.action` chain is
/// unwound, and the resulting state change is applied — all before `send` returns.
/// If `update` returns a `.task`, that task is *launched* synchronously but runs
/// concurrently; `send` does not wait for it to finish. Use ``request(_:)`` if you
/// need to await the task's completion.
///
/// If you want to fire-and-forget the event — scheduling it without waiting for even
/// the synchronous `update` pass to complete — use ``post(_:)`` instead.
///
/// - Warning: Because `send` unwinds `.action` chains synchronously on the Actor,
/// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … —
/// will loop forever and hang the main thread. ``post(_:)`` and ``request(_:)`` are
/// immune because each re-entry is scheduled as a new task, yielding control between iterations.
/// - Parameter event: The event to send into the actor-hosted runtime.
/// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has
/// already been released, plus any error that ``EffectActor/send(_:)``
/// would throw for the same event.
public func send(_ event: sending Event) async throws {
guard let actor else {
throw RuntimeError.actorDeallocated
}
try await actor.send(event)
}

/// Schedules `event` on the Actor without awaiting it.
///
/// Safe to call from any actor isolation or non-isolated context.
/// Use this to fire-and-forget an event from a background task or a
/// non-isolated callback without waiting for `update` to run.
///
/// - Parameter event: The event to enqueue into the actor-hosted runtime.
/// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has
/// already been released.
@inline(__always)
public func post(_ event: sending Event) throws {
guard let actor else {
throw RuntimeError.actorDeallocated
}
// TODO: try actor.checkRuntimeAvailability()
Task {
try? await actor.send(event)
}
}

/// Sends `event` and suspends until the entire resulting effect chain has completed,
/// returning the `Output?` value produced by the terminal `.task` closure.
///
/// A single event can trigger a cascade: an `.action` may return the next event to
/// process immediately, which in turn may return another, and so on. The continuation
/// is threaded through the whole chain and only resumed when the chain reaches a
/// terminal effect — typically a `.task`, whose async operation runs to completion
/// before `request` returns.
///
/// ```
/// event → [.action chain] → terminal effect
/// ├─ .task → Output?
/// ├─ .cancel → nil
/// └─ nil → nil
/// ```
///
/// The caller hops to the Actor for the duration of the call.
///
/// - Note: If the calling `Task` is cancelled while suspended,
/// `request` continues to wait until the effect chain settles.
/// - Note: If the actor-hosted runtime has already shut down, `request`
/// throws ``RuntimeError`` immediately instead of entering the runtime.
/// - Parameter event: The event to send into the actor-hosted runtime.
/// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has
/// already been released, plus any error that ``EffectActor/request(_:)``
/// would throw for the same event.
/// - Returns: The terminal `Output?` value produced by the settled effect chain.
///
/// For usage patterns including `.refreshable`, `task(id:)`, and testing,
/// see <doc:BridgingEventDrivenAndImperative>.
@discardableResult
public func request(_ event: Event) async throws -> Output? {
guard let actor else {
throw RuntimeError.actorDeallocated
}
return try await actor.request(event)
}

/// Convenience call-as-function syntax for ``post(_:)``.
///
/// - Parameter event: The event to enqueue into the observable runtime.
/// - Throws: Any error that ``post(_:)`` would throw for the same event.
@inline(__always)
public func callAsFunction(_ event: sending Event) throws {
try post(event)
}
}
}
Loading
Loading