From 8bbbc557070ceacd09a5aed9a11631967c190274 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Thu, 4 Jun 2026 12:55:55 +0200 Subject: [PATCH] feat: Add send(_:) to protocol TransducerInput - Introduced explicit acknowledgment semantics for immediate event processing via TransducerInput.send(_:) - Added TransducerInput.send(_:) async throws. - Added Effect.send(_:) for synchronous in-turn event dispatch. - Deprecated Effect.event(_:). - Added comprehensive Transducer usage examples. - Expanded concurrency and actor-isolation docs for async actions. - Clarified TransducerInput dispatch semantics and backpressure behavior. - Added migration guidance for replacing event(_:) with send(_:). - Refactored deprecated APIs to delegate to new helpers. - Updated test stubs and mock inputs for protocol conformance. - Renamed Xcode scheme and test targets from EffectView to EffectComponents. --- ...iew.xcscheme => EffectComponents.xcscheme} | 18 +-- .../Transducer/Transducer.Effects.swift | 47 +++++++- .../Transducer/Transducer.swift | 104 ++++++++++++++++++ .../Transducer/TransducerInput.swift | 26 ++++- Tests/EffectComponents/EffectViewTests.swift | 6 + .../RunFailureLifecycleTests.swift | 2 + 6 files changed, 192 insertions(+), 11 deletions(-) rename .swiftpm/xcode/xcshareddata/xcschemes/{EffectView.xcscheme => EffectComponents.xcscheme} (83%) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme similarity index 83% rename from .swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme index bda2159..3476502 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme @@ -15,9 +15,9 @@ buildForAnalyzing = "YES"> @@ -34,9 +34,9 @@ skipped = "NO"> @@ -63,9 +63,9 @@ diff --git a/Sources/EffectComponents/Transducer/Transducer.Effects.swift b/Sources/EffectComponents/Transducer/Transducer.Effects.swift index 32ad978..61f27a3 100644 --- a/Sources/EffectComponents/Transducer/Transducer.Effects.swift +++ b/Sources/EffectComponents/Transducer/Transducer.Effects.swift @@ -167,6 +167,17 @@ extension Transducer where Effect == TransducerEffect { .init(._actionAsync(action)) } + /// Return an effect which when invoked executes an async step on a user specified global actor. + /// + /// The `action` closure receives `Env`. It runs synchronously on the system actor before any + /// other work proceeds. + /// + /// Async actions still do not create managed task identities of their own. Any + /// overlap semantics apply only once the chain reaches a named terminal task. + /// + /// - Parameter action: A synchronous closure receiving `Env`. + /// + /// - Returns: An effect. @inline(__always) public static func action( _ action: sending @escaping @isolated(any) (Env) async -> Void @@ -203,6 +214,17 @@ extension Transducer where Effect == TransducerEffect { .init(._actionAsyncIsolated(action)) } + /// Return an effect which when invoked executes an async step on the system actor. + /// + /// The `action` closure receives `Env`. It runs synchronously on the system actor before any + /// other work proceeds. + /// + /// Async isolated actions still do not create managed task identities of their own. + /// Any overlap semantics apply only once the chain reaches a named terminal task. + /// + /// - Parameter action: A synchronous closure receiving `Env`. + /// + /// - Returns: An effect. @inline(__always) public static func action( _ action: sending @escaping (Env, isolated any Actor) async -> Void @@ -214,14 +236,37 @@ extension Transducer where Effect == TransducerEffect { } + /// Returns an effect which, when invoked, feeds an event back into `update` immediately + /// in the current synchronous turn. + /// + /// Prefer this helper over the deprecated `event(_:)` to avoid naming conflicts with the + /// `update(_ state:event:)` parameter named `event`. + /// + /// - Parameter event: The next event to feed directly back into `update`. + /// - Returns: An effect. + @inline(__always) + public static func send(_ event: Event) -> Effect { + .init(._event(event)) + } + /// Returns an effect which when invoked feeds `event` back into `update` immediately, in /// the current synchronous turn. /// + /// ```swift + /// // Before: ambiguous inside `update` due to parameter + /// // named `event` + /// // return Self.event(.tick) + /// + /// // After: preferred helper + /// return send(.tick) + /// ``` + /// /// - Parameter event: The next event to feed directly back into `update`. /// - Returns: An effect. + @available(*, deprecated, message: "Use send(_:) instead to avoid shadowing with the `update` parameter named `event`.") @inline(__always) public static func event(_ event: Event) -> Effect { - .init(._event(event)) + send(event) } /// Returns an effect which cancels the running task with the given identifier, if any. diff --git a/Sources/EffectComponents/Transducer/Transducer.swift b/Sources/EffectComponents/Transducer/Transducer.swift index f81f128..382e7a5 100644 --- a/Sources/EffectComponents/Transducer/Transducer.swift +++ b/Sources/EffectComponents/Transducer/Transducer.swift @@ -11,6 +11,110 @@ import Foundation /// `update` mutates state synchronously and may return an effect describing /// follow-up work. That work can emit more events later, but direct state /// mutation still flows back through `update`. +/// +/// +/// Example +/// ------- +/// A minimal counter feature showing how to declare `State`, an `Event` enum (note the enum), +/// the `update` reducer, and an `output` that returns a value from state. +/// +/// ```swift +/// enum CounterTransducer: Transducer { +/// // Feature state owned by the runtime +/// struct State { +/// var count: Int = 0 +/// } +/// +/// // Domain events that drive state transitions +/// // (note: this is an enum) +/// enum Event { +/// case increment +/// case decrement +/// case reset +/// case requestCurrentValue // e.g., a request-style event +/// } +/// +/// // No external dependencies for this simple example +/// typealias Env = Void +/// +/// // The effect returned by `update` defaults to +/// // `TransducerEffect` +/// // We keep `Output` as `Int` to return the current +/// // counter value from `output`. +/// typealias Output = Int +/// +/// // Synchronous state mutation and next-effect +/// // selection +/// static func update( +/// _ state: inout State, event: Event +/// ) -> Effect? { +/// switch event { +/// case .increment: +/// state.count += 1 +/// return nil +/// +/// case .decrement: +/// state.count -= 1 +/// return nil +/// +/// case .reset: +/// state.count = 0 +/// return nil +/// +/// case .requestCurrentValue: +/// // In a request flow, you can choose to end the +/// // chain here with no further effect. +/// // The runtime will call `output(state:event:)` +/// // to produce the result. +/// return nil +/// } +/// } +/// +/// // Produces the terminal result for request-style +/// // chains +/// static func output( +/// state: State, event: Event +/// ) -> Output { +/// // For this simple example, always return +/// // the current count +/// state.count +/// } +/// } +/// ``` +/// +/// Requesting the current value +/// ---------------------------- +/// If your runtime provides an `Input` that conforms to `TransducerInput`, you can +/// request the current value by sending a request-style event and awaiting the output: +/// +/// ```swift +/// // Pseudocode demonstrating usage inside +/// // a host runtime +/// var state = CounterTransducer.State() +/// let taskManager = TaskManager() +/// let input: (any TransducerInput)? = /* provided by host */ +/// let env: CounterTransducer.Env = () +/// +/// // Fire some events that mutate state +/// try await CounterTransducer.compute( +/// event: .increment, +/// continuation: nil, +/// state: &state, +/// taskManager: taskManager, +/// input: input, +/// env: env +/// ) +/// +/// // Later, request the current value. Depending on +/// // your host, this might be: +/// if let input { +/// // Resume with the value produced by +/// // `output(state:event:)` +/// let value: Int? = await input.request(.requestCurrentValue) +/// // `value` is the current `state.count` at the +/// // time the request settles +/// } +/// ``` public protocol Transducer: SendableMetatype { /// Mutable feature state owned by the host runtime. associatedtype State diff --git a/Sources/EffectComponents/Transducer/TransducerInput.swift b/Sources/EffectComponents/Transducer/TransducerInput.swift index 4a7f031..447c8f7 100644 --- a/Sources/EffectComponents/Transducer/TransducerInput.swift +++ b/Sources/EffectComponents/Transducer/TransducerInput.swift @@ -1,9 +1,10 @@ /// A handle for feeding events back into a transducer runtime. /// -/// `TransducerInput` has two dispatch styles: +/// `TransducerInput` has three dispatch styles: /// /// - ``post(_:)`` sends an event without awaiting a result. +/// - ``send(_:)`` suspends until the immediate reduction path has been processed. /// - ``request(_:)`` suspends until the triggered effect chain settles and returns /// the terminal `Output?` value, if any. /// @@ -21,6 +22,29 @@ public protocol TransducerInput: SendableMetatype { associatedtype Event associatedtype Output + + /// Sends `event` and suspends until the runtime has processed its immediate + /// reduction path. + /// + /// `send` is a deliberate backpressure boundary. It returns only after the + /// runtime has run `update` for `event` and any immediately returned + /// event/action chain, or after that chain has reached a terminal managed + /// task. If a task effect is started, `send` returns after the task has been + /// accepted by the runtime; it does not wait for the task operation to + /// complete and it does not return `Output`. + /// + /// Custom queued hosts must preserve this semantic. If events are transported + /// through a channel or mailbox, `send` must be acknowledged after the + /// consumer has processed the immediate reduction path, not merely after the + /// event has been enqueued. A queued implementation typically transports an + /// envelope carrying either no continuation (`post`), an acknowledgement + /// continuation (`send`), or an output continuation (`request`). + /// + /// - Parameter event: The event to send into the runtime. + /// - Throws: A runtime entry failure if the event cannot be accepted, or a + /// later cancellation or runtime failure before the immediate reduction path + /// has been acknowledged. + func send(_ event: sending Event) async throws /// Schedules `event` without awaiting the resulting effect chain. /// diff --git a/Tests/EffectComponents/EffectViewTests.swift b/Tests/EffectComponents/EffectViewTests.swift index 59a0bc2..c5b0133 100644 --- a/Tests/EffectComponents/EffectViewTests.swift +++ b/Tests/EffectComponents/EffectViewTests.swift @@ -662,6 +662,12 @@ private final class EventSpy: @unchecked Sendable { private struct TaskInput: TransducerInput, Sendable { let onEvent: @Sendable @MainActor (Event) -> Void + func send(_ event: sending Event) async throws { + await MainActor.run { + onEvent(event) + } + } + func post(_ event: sending Event) { Task { @MainActor in onEvent(event) diff --git a/Tests/EffectComponents/RunFailureLifecycleTests.swift b/Tests/EffectComponents/RunFailureLifecycleTests.swift index b43a36e..bc735ab 100644 --- a/Tests/EffectComponents/RunFailureLifecycleTests.swift +++ b/Tests/EffectComponents/RunFailureLifecycleTests.swift @@ -15,6 +15,8 @@ struct RunFailureLifecycleTests { } private struct StubInput: TransducerInput, Sendable { + func send(_ event: sending Event) async throws {} + func post(_ event: sending Event) throws {} func request(_ event: Event) async throws -> Output? {