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
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectView"
BuildableName = "EffectView"
BlueprintName = "EffectView"
BlueprintIdentifier = "EffectComponents"
BuildableName = "EffectComponents"
BlueprintName = "EffectComponents"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
Expand All @@ -34,9 +34,9 @@
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectViewTests"
BuildableName = "EffectViewTests"
BlueprintName = "EffectViewTests"
BlueprintIdentifier = "EffectComponentsTests"
BuildableName = "EffectComponentsTests"
BlueprintName = "EffectComponentsTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
Expand All @@ -63,9 +63,9 @@
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectView"
BuildableName = "EffectView"
BlueprintName = "EffectView"
BlueprintIdentifier = "EffectComponents"
BuildableName = "EffectComponents"
BlueprintName = "EffectComponents"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
Expand Down
47 changes: 46 additions & 1 deletion Sources/EffectComponents/Transducer/Transducer.Effects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ extension Transducer where Effect == TransducerEffect<Event, Env, Output> {
.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
Expand Down Expand Up @@ -203,6 +214,17 @@ extension Transducer where Effect == TransducerEffect<Event, Env, Output> {
.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
Expand All @@ -214,14 +236,37 @@ extension Transducer where Effect == TransducerEffect<Event, Env, Output> {
}


/// 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.
Expand Down
104 changes: 104 additions & 0 deletions Sources/EffectComponents/Transducer/Transducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event, Env, Output>`
/// // 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<CounterTransducer.Output>()
/// let input: (any TransducerInput<CounterTransducer.Event, CounterTransducer.Output>)? = /* 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
Expand Down
26 changes: 25 additions & 1 deletion Sources/EffectComponents/Transducer/TransducerInput.swift
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -21,6 +22,29 @@
public protocol TransducerInput<Event, Output>: 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.
///
Expand Down
6 changes: 6 additions & 0 deletions Tests/EffectComponents/EffectViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,12 @@ private final class EventSpy<Event: Sendable>: @unchecked Sendable {
private struct TaskInput<Event: Sendable, Output: Sendable>: 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)
Expand Down
2 changes: 2 additions & 0 deletions Tests/EffectComponents/RunFailureLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ struct RunFailureLifecycleTests {
}

private struct StubInput<Event, Output>: TransducerInput, Sendable {
func send(_ event: sending Event) async throws {}

func post(_ event: sending Event) throws {}

func request(_ event: Event) async throws -> Output? {
Expand Down
Loading