From 331956b17b57e86ab800d0e560577f4f5e5da31a Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Wed, 13 May 2026 13:36:32 +0200 Subject: [PATCH] feat: add Output type parameter and request dispatch strategy - Effect gains a third generic parameter Output for values returned to callers of Input.request(_:) - Input.request(_:) suspends the caller until the effect completes, returning Output? (replaces perform(_:)) - Effect.run and Effect.request factory methods make the fire-and- forget vs. perform-driven intent explicit - EffectView threads Output through all public and internal signatures - Examples, tests, and ObservationHelpers updated for the new shape - Add BridgingEventDrivenAndImperative.md --- .../BridgingEventDrivenAndImperative.md | 215 ++++++++++++++++++ .../EffectViewExample/Counter.swift | 4 +- .../EffectViewExample/Movies.swift | 8 +- .../EffectViewExample/RemoteCounter.swift | 10 +- README.md | 7 +- Sources/EffectView/Effect.swift | 145 ++++++++++-- Sources/EffectView/EffectView.swift | 183 +++++++-------- Sources/EffectView/EnvReader.swift | 24 +- Sources/EffectView/Input.swift | 105 ++++++--- Sources/EffectView/ObservationHelpers.swift | 46 ++-- Tests/EffectViewTests/EffectViewTests.swift | 128 +++++++---- 11 files changed, 642 insertions(+), 233 deletions(-) create mode 100644 Documentation/BridgingEventDrivenAndImperative.md diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md new file mode 100644 index 0000000..460ab37 --- /dev/null +++ b/Documentation/BridgingEventDrivenAndImperative.md @@ -0,0 +1,215 @@ +# Bridging Event-Driven and Imperative Code + +EffectView is event-driven: views fire events, `update` mutates state, effects +run as a consequence. This model is clean, testable, and predictable — but it +has one widely-cited pain point: + +> "I tried event-driven, but it doesn't work with `.refreshable`." + +This is true for naive event dispatch. It is not true for EffectView. + +--- + +## The problem + +SwiftUI's `.refreshable` modifier expects the supplied `async` closure to stay +suspended for as long as the refresh is in progress. The moment the closure +returns, the spinner stops. If you fire an event and return immediately, the +spinner disappears before the data arrives: + +```swift +.refreshable { + send(.refresh) // returns instantly; spinner stops too early +} +``` + +The same issue arises for any SwiftUI feature that awaits an async closure: +`task(id:)`, `searchable` with an async suggestions closure, button actions in +`.toolbar`, sheet confirmations, and so on. + +--- + +## The solution: `request(_:)` + +`Input.request(_:)` suspends the caller until the entire resulting effect chain +has settled and returns an optional `Output` value: + +```swift +.refreshable { + await input.request(.refresh) + // resumes only when .refresh has been + // fully processed and the effect settled +} +``` + +The spinner stays active for exactly as long as the work takes — no polling, +no extra state flag, no manual `Task` management. + +--- + +## How it works + +When `request` is called, a `CheckedContinuation` is created and threaded +through the effect chain alongside the event. The continuation is not resumed +until the chain reaches a terminal point: + +``` +event → [.action chain] → terminal effect + ├─ .task → Output? + ├─ .cancel → nil + └─ nil → nil +``` + +Critically, the continuation travels *inside* the effect graph. The `.task` +closure does not need to know a caller is waiting — it just returns a value, +and the engine forwards it to the suspended caller automatically. + +This is what distinguishes `request` from external state-polling approaches +like XState's `waitFor`: the caller does not observe state changes from +outside; it dispatches an event and awaits the FSM settling as a direct +consequence of that event. + +--- + +## Real-world patterns + +### Pull-to-refresh + +```swift +.refreshable { + await input.request(.refresh) +} +``` + +`update` handles `.refresh` by returning a `.task` that fetches data and +sends `.loaded(data)`. `request` resumes when the task closure returns. + +### Navigation confirmation + +A sheet's "Save" button can await the result of a save operation before +dismissing: + +```swift +Button("Save") { + Task { + let saved = await input.request(.save) + if saved != nil { dismiss() } + } +} +``` + +### Async `task(id:)` + +When the app regains foreground, re-fetch only if the previous task has +settled: + +```swift +.task(id: appPhase) { + if appPhase == .active { + await input.request(.resumeIfNeeded) + } +} +``` + +### Testing + +`request` makes integration tests straightforward — no `XCTestExpectation` +or polling required: + +```swift +let result = await input.request(.load) +XCTAssertEqual(state.items.count, 3) +``` + +--- + +## Comparison with other approaches + +| Approach | Stays suspended? | Returns a value? | Always resumes? | +|---|---|---|---| +| `send(.refresh)` | No | — | — | +| `XCTestExpectation` / `Task.sleep` | Roughly | No | No | +| XState `waitFor(predicate)` | Yes | No | No | +| TCA `store.send(.refresh).finish()` | Yes | No | No | +| ImmutableData `dispatcher.dispatch` | No | — | — | +| Akka `actor ? message` | Yes | Yes (explicit reply) | No | +| `input.request(.refresh)` | Yes | Yes (`Output?`) | Yes | + +### TCA: `StoreTask.finish()` + +The Composable Architecture has a direct answer: `store.send(_:)` returns a +`StoreTask`, and `await storeTask.finish()` suspends until all effects +launched by that action complete. + +```swift +// TCA +.refreshable { + await store.send(.refresh).finish() +} +``` + +This works well for `.refreshable` and handles common edge cases correctly. +The differences from `request` are design choices, not deficiencies: + +- **No return value.** Data flows back through state observation, not as a + typed return. The caller cannot write `let result = await ...`. +- **Cancellation propagates.** If the outer `Task` is cancelled, TCA cancels + the effect task. In practice SwiftUI holds the `.refreshable` task alive + for the full gesture, so this behaves correctly in normal use. The + propagation is intentional — it makes TCA effects participants in + structured concurrency rather than escaping it. +- **Waits for all effects.** `finish()` waits until every effect spawned by + the action exits. `request` waits only until the specific continuation is + resolved. Both are correct; they reflect different granularity. + +The two approaches are complementary. `StoreTask.finish()` fits TCA's +long-lived store model where effects participate in structured cancellation. +`request` fits EffectView's view-scoped FSM model where the `@MainActor` +lifetime is the safety net and a typed return value is useful. + +### ImmutableData + +ImmutableData has no equivalent mechanism. `dispatcher.dispatch(action:)` is +synchronous and `throws` but not `async`. Effects are not first-class values +returned from the reducer — side effects are expected to be managed outside +the store, typically via `@Observable` objects or Combine publishers that +react to state changes. + +As a result, bridging to `.refreshable` requires a workaround: a state flag +(`isRefreshing: Bool`) that the view observes, combined with a polling or +`AsyncStream` approach to detect when the flag clears. + +```swift +// ImmutableData — workaround required +.refreshable { + dispatcher.dispatch(action: .refresh) + // must poll or observe isRefreshing to know + // when to let the closure return +} +``` + +This is the class of problem that prompted the "event-driven doesn't work +with `.refreshable`" complaint. ImmutableData does not address it. + +### Akka `ask` pattern + +Akka's `actor ? message` (the "ask" pattern) is the closest prior art: +request-response over a message-passing system. The difference is that Akka +requires the actor to explicitly send a reply message. Here, the reply is the +return value of the `.task` closure — the continuation threading is +transparent to the task author. + +--- + +## Design rationale + +The goal was to make event-driven code a first-class citizen in SwiftUI +without requiring callers to adopt a different programming model for +asynchronous flows. SwiftUI's async integration points (`refreshable`, +`task(id:)`, etc.) are built around `async`/`await`. `request` meets them +where they are. + +The continuation-threading mechanism means there is no semantic difference +between "I fired this event and don't care about the result" (`send` / +`enqueue`) and "I fired this event and need to know when it's done" +(`request`). The FSM is identical in both cases; only the call site differs. diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift index 54d2fd1..c957f9d 100644 --- a/Examples/EffectViewExample/EffectViewExample/Counter.swift +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -50,11 +50,11 @@ extension Counter { private static func update( state: inout ViewState, event: Event - ) -> Effect? { + ) -> Effect? { switch event { case .start: state.counter = 0 - return .task(name: "Counter") { input, env in + return .run(name: "Counter") { input, env in while true { do { try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift index cffadea..32c8766 100644 --- a/Examples/EffectViewExample/EffectViewExample/Movies.swift +++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift @@ -83,7 +83,7 @@ extension Movies { case .content(let movies): List(movies, rowContent: MovieRow.init) .refreshable { - await input.perform(.refresh) + await input.request(.refresh) } } @@ -154,7 +154,7 @@ extension Movies.MovieListView { } @MainActor - static func update(state: inout ViewState, event: Event) -> Effect? { + static func update(state: inout ViewState, event: Event) -> Effect? { switch event { case .load: // Guard against refresh: can only race with programmatic load triggers @@ -204,7 +204,7 @@ extension Movies.MovieListView { extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { static func loadMovies() -> Self { - .task(name: "load") { input, env in + .run(name: "load") { input, env in do { let movies = try await env.movieFetch() input(.loaded(movies)) @@ -216,7 +216,7 @@ extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { static func refreshMovies() -> Self { // Note: a refresh action - .task(name: "refresh") { input, env in + .run(name: "refresh") { input, env in do { let movies = try await env.movieFetch() input(.loaded(movies)) diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift index e3b6011..bf53699 100644 --- a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift +++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift @@ -75,7 +75,7 @@ extension RemoteCounter.Views { static func update( _ state: inout ViewState, event: Event - ) -> Effect? { + ) -> Effect? { switch event { case .start: @@ -84,7 +84,7 @@ extension RemoteCounter.Views { name: "observe-store-count" ) { @MainActor input, value in print("observe-store-count: ", value) - await input.perform(.storeChanged(newCount: value)) + await input.request(.storeChanged(newCount: value)) } case .storeChanged(let newCount): @@ -96,17 +96,17 @@ extension RemoteCounter.Views { return nil case .incrementTapped: - return .task { _, env in + return .run { _, env in await env.store.send(.increment) } case .decrementTapped: - return .task { _, env in + return .run { _, env in await env.store.send(.decrement) } case .resetTapped: - return .task { _, env in + return .run { _, env in await env.store.send(.reset) } } diff --git a/README.md b/README.md index 3ca471a..fc93fc7 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,12 @@ The return type of `update`. Controls what happens after a state mutation. | Case | Purpose | |---|---| | `.task(name:priority:operation:)` | Starts an async operation. Named tasks are automatically cancelled and replaced if re-issued. | -| `.action(action:)` | Synchronous step; the returned `Event?` is processed immediately in the same run loop. | +| `.action(action:)` | Synchronous step; the returned `Event?` is processed immediately in the same run loop. See warning below. | | `.cancel(name)` | Cancels a running named task. | +> **Warning — `.action` cycles block the main thread.** +> Because `.action` chains are unwound synchronously on the `@MainActor`, a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — will loop forever and hang the app. Keep action chains finite and acyclic. If you need iterative or potentially unbounded work, use a `.task` instead, where each iteration suspends and yields control back to the system. + Returning `nil` means no effect — state was mutated but no async work is needed. ### Custom effects @@ -320,6 +323,8 @@ case .loadConfig: return .task { input, env in … } ``` +> **Warning:** Action chains run entirely on the `@MainActor` without yielding. A cycle — two events that each produce an `.action` pointing back at the other — will hang the main thread. Prefer `.task` for any work that could repeat or loop. + --- ### Fire-and-forget (button / gesture) diff --git a/Sources/EffectView/Effect.swift b/Sources/EffectView/Effect.swift index 46ba4de..a400966 100644 --- a/Sources/EffectView/Effect.swift +++ b/Sources/EffectView/Effect.swift @@ -1,54 +1,151 @@ -/// Describes a side effect returned from `update`. +/// A value describing a side effect to run after a state transition. /// -/// `Env` is forwarded to every effect so operations and actions have access to -/// dependencies without capturing them directly: +/// `update` returns an `Effect` to declare what async or synchronous work should +/// happen next. The effect engine executes it; `update` itself stays synchronous +/// and free of side effects. `Env` is forwarded to every effect so operations and +/// actions can access dependencies without capturing them at the call site. /// /// ```swift -/// // In update: -/// return .task { input, env in // env forwarded here -/// let result = await env.service.fetch() -/// input(.loaded(result)) +/// // Fire-and-forget task: +/// return .run(name: "ticker") { input, env in +/// while true { +/// try await env.clock.sleep(for: .seconds(1)) +/// input(.tick) +/// } /// } /// -/// return .action { env in // env forwarded here +/// // Perform-driven task (caller awaits result): +/// return .request(name: "load") { input, env in +/// let user = await env.api.fetchUser() +/// return await input.request(.loaded(user)) +/// } +/// +/// // Synchronous step — next event returned inline: +/// return .action { env in /// env.analytics.track(.buttonTapped) /// return .next /// } /// ``` -public enum Effect { +/// +/// ### Generic parameters +/// +/// - `Event`: The event type of the FSM this effect belongs to. +/// - `Env`: The dependency environment forwarded into every task and action closure. +/// - `Output`: The value type returned to a caller suspended on ``Input/request(_:)``. +/// Use `Void` when no return value is needed. +public enum Effect { - /// Starts an async operation. The `operation` closure receives a `send` function - /// and the captured `Env`. Named tasks are cancelled and replaced if re-issued. + /// Starts an async operation tracked by the effect engine. + /// + /// The `operation` closure receives an ``Input`` handle for dispatching events and + /// the captured `Env` for dependencies. Named tasks are automatically cancelled when + /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the + /// same name. If a task with the same name is already running, it is cancelled before + /// the new one starts. + /// + /// Prefer ``run(name:priority:operation:)`` for fire-and-forget tasks and + /// ``request(name:priority:operation:)`` for perform-driven tasks rather than + /// constructing `.task` directly. + /// + /// - Parameters: + /// - name: An optional name used to track and cancel the task. Pass `nil` for + /// anonymous tasks that run to completion without cancellation support. + /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit + /// the current task's priority. + /// - operation: The async work to perform. Returns an optional `Output` value + /// forwarded to any caller suspended on ``Input/request(_:)``. case task( name: String? = nil, priority: TaskPriority? = nil, - operation: @Sendable @isolated(any) (Input, Env) async -> Void + operation: @Sendable @isolated(any) (Input, Env) async -> Output? ) - /// A synchronous step. The `action` closure receives `Env` and may return the - /// next `Event` to process immediately in the same run loop. + /// A synchronous step that may produce the next event to process immediately. + /// + /// The `action` closure receives `Env` and returns the next `Event` to feed back + /// into `update`, or `nil` to end the chain. The entire chain runs synchronously + /// on the `@MainActor` before any other work proceeds. + /// + /// - Parameter action: A synchronous closure receiving `Env` and returning an + /// optional next event. + /// + /// - Warning: Action chains unwind entirely on the `@MainActor` without yielding. + /// A cycle — two events that each produce an `.action` pointing back at the other — + /// will hang the main thread. Use ``run(name:priority:operation:)`` for any work + /// that could repeat or loop. case action( action: @Sendable (Env) -> Event? ) - /// Sends the given event back into the system which will be processed - /// immediately. + /// Feeds `event` back into `update` immediately, in the current synchronous turn. case event(Event) - /// Cancels a running named task. + /// Cancels the running task with the given name, if any. case cancel(String) - /// Executes a list of effects left to right. Each effect is processed in order; - /// only the last effect in the sequence is associated with the caller's continuation. - /// - /// Intermediate effects must be synchronous and terminal (`.cancel`, side-effect - /// `.action` closures). Intermediate effects that return an event are not supported - /// and the event will be discarded — use a separate `update` step for event chains. + /// Runs a list of effects left to right, associating the caller's continuation + /// with the last effect only. /// /// ```swift /// // Cancel a stale load before starting a refresh: /// return .sequence([.cancel("load"), .refreshMovies()]) /// ``` - case sequence([Effect]) + /// + /// - Important: Intermediate effects must be synchronous and terminal (`.cancel` + /// or side-effect `.action` closures). An intermediate effect that returns an + /// event is not supported — the event is silently discarded. Use a dedicated + /// `update` step for event-producing chains instead. + case sequence([Effect]) +} + +extension Effect { + + /// Starts a fire-and-forget async task that communicates back through events. + /// + /// Use for long-running background work — timers, observers, subscriptions — where + /// the caller does not need to await a result. The `operation` closure receives an + /// ``Input`` handle and the captured `Env`; any return value is discarded. + /// + /// ```swift + /// return .run(name: "ticker") { input, env in + /// do { + /// while true { + /// try await env.clock.sleep(for: .seconds(1)) + /// input(.tick) + /// } + /// } catch {} + /// } + /// ``` + public static func run( + name: String? = nil, + priority: TaskPriority? = nil, + operation: @escaping @Sendable @isolated(any) (Input, Env) async -> Void + ) -> Self where Env: Sendable { + .task(name: name, priority: priority) { input, env in + await operation(input, env) + return nil + } + } + + /// Starts an async task whose result is returned to the caller of ``Input/request(_:)``. + /// + /// The `operation` closure performs its work, drives the FSM to a completion event + /// via `await input.request(...)`, and returns the resulting `Output?` to the + /// original waiter. Use this when the call site needs to `await` the outcome of an + /// async operation. + /// + /// ```swift + /// return .request(name: "load") { input, env in + /// let user = await env.api.fetchUser() + /// return await input.request(.loaded(user)) + /// } + /// ``` + public static func request( + name: String? = nil, + priority: TaskPriority? = nil, + operation: @escaping @Sendable @isolated(any) (Input, Env) async -> Output? + ) -> Self { + .task(name: name, priority: priority, operation: operation) + } } diff --git a/Sources/EffectView/EffectView.swift b/Sources/EffectView/EffectView.swift index 56e4cfb..54c24eb 100644 --- a/Sources/EffectView/EffectView.swift +++ b/Sources/EffectView/EffectView.swift @@ -1,13 +1,13 @@ import SwiftUI -/// A SwiftUI view that manages structured side effects using an Elm-style update loop. +/// A SwiftUI view that manages structured side effects via an Elm-style update loop. /// -/// `EffectView` owns an `EffectManager` for the duration of the view's lifetime. -/// State is owned by the caller (via `Binding`) so ancestor views can observe changes. -/// The `update` function is the single mutation point: it receives an event, mutates -/// state, and optionally returns an `Effect` to run or cancel. +/// `EffectView` owns the task scheduler for the duration of its view identity. +/// State is held by the caller via `Binding` so ancestor views can observe changes. +/// `update` is the single mutation point: it receives an event, mutates state, and +/// optionally returns an ``Effect`` to run or cancel. /// -/// ## Basic usage (no dependencies) +/// ### Basic usage /// /// ```swift /// enum Event { case increment, reset } @@ -15,99 +15,86 @@ import SwiftUI /// /// @State private var state = MyState() /// -/// EffectView(state: $state, update: { state, event -> Effect? in -/// switch event { -/// case .increment: state.count += 1; return nil -/// case .reset: state.count = 0; return nil +/// EffectView( +/// state: $state, +/// update: { state, event in +/// switch event { +/// case .increment: state.count += 1; return nil +/// case .reset: state.count = 0; return nil +/// } /// } -/// }) { state, send in +/// ) { state, send in /// Button("\(state.count)") { send(.increment) } /// } /// ``` /// -/// ## Using `Env` for dependencies +/// ### Using `Env` for dependencies /// -/// Pass dependencies (clocks, network clients, etc.) via `Env`. The value is -/// captured once when the view appears and forwarded to every effect operation. +/// Pass dependencies (clocks, API clients, etc.) via `Env`. The value is captured +/// once when the view appears and forwarded to every effect. /// /// ```swift -/// struct Env { let clock: any Clock } +/// struct Env { let api: any APIClient } /// /// EffectView( /// state: $state, -/// initialEnv: Env(clock: ContinuousClock()), +/// initialEnv: Env(api: liveAPI), /// update: { state, event in -/// switch event { -/// case .start: -/// return .run(name: "ticker") { input, env in -/// do { -/// while true { -/// try await env.clock.sleep(for: .seconds(1)) -/// input(.tick) -/// } -/// } catch { -/// // cancellation or clock error — signal via event if needed +/// switch event { +/// case .load: +/// return .run(name: "load") { input, env in +/// let data = await env.api.fetch() +/// input(.loaded(data)) /// } +/// case .loaded(let data): +/// state.data = data; return nil /// } -/// case .tick: state.count += 1; return nil -/// case .stop: return .cancel("ticker") /// } -/// }) { state, send in -/// Button("Start") { send(.start) } +/// ) { state, send in +/// Button("Load") { send(.load) } /// } /// ``` /// -/// ## Env changes -/// -/// If `Env` changes during the view's lifetime, running effects keep using the -/// original captured value. This is intentional because swapping dependencies -/// mid-flight can cause subtle bugs (for example, a task started with mock services -/// finishing after a switch to production services). To restart the view with new -/// dependencies, apply `.id(env)` at the call site (requires `Env: Hashable`). This -/// destroys the old view — cancelling all tasks — and creates a fresh instance with -/// the updated `Env`. +/// ### Env changes /// -/// ## Synchronous action chains +/// If `Env` changes during the view's lifetime, running effects keep the original +/// captured value. To restart with new dependencies, apply `.id(env)` at the call +/// site (requires `Env: Hashable`). This destroys the old view — cancelling all +/// tasks — and creates a fresh instance with the updated `Env`. /// -/// The `.action` effect is a synchronous step that may return the next event. Each -/// returned event is processed immediately in the same run loop before any external -/// events are handled. This provides a deterministic sequence for setup work -/// (e.g. create an instance, store it in state, then continue processing). The chain -/// continues only while effects return `.action`; returning `.task` or `.cancel` ends -/// the synchronous chain. +/// ### Generic parameters /// +/// - `State`: The type of the view's mutable state. +/// - `Event`: The event type driving state transitions. +/// - `Env`: The dependency environment. Use `Void` for no dependencies. +/// - `Output`: The value returned to callers of ``Input/request(_:)``. +/// Use `Void` when no return value is needed. +/// - `Content`: The view builder output type. @MainActor public struct EffectView< State, Event, Env: Sendable, + Output: Sendable, Content: View >: View { - @SwiftUI.State private var input: Input? = nil + @SwiftUI.State private var input: Input? = nil private var state: Binding private var initialEvent: Event? private let env: Env - private var update: (inout State, Event) -> Effect? - private let content: (State, Input) -> Content + private var update: (inout State, Event) -> Effect? + private let content: (State, Input) -> Content - /// Creates an `EffectView` and captures `initialEnv` and `update` for the lifetime of this view identity. + /// Creates an effect-managed view with a captured dependency environment. /// - /// `initialEvent`, `initialEnv`, and `update` values are captured once when the view appears the first time. - /// Later changes to `initialEnv` or `update` are intentionally ignored to avoid mid-flight dependency - /// changes during running effects. To restart with new dependencies, recreate the view identity with - /// `.id(...)`. + /// `initialEvent`, `initialEnv`, and `update` are captured once when the view + /// appears for the first time. Later changes are intentionally ignored to avoid + /// mid-flight dependency swaps during running effects. To restart with new + /// dependencies, use `.id(env)` at the call site (requires `Env: Hashable`). /// - /// - Parameters: - /// - state: A `Binding` to the view's state, owned by the caller. - /// - initialEvent: An optional initial event to send when the view appears for the first time. - /// - initialEnv: An environment value to capture for the lifetime of this view identity. - /// - update: A function that updates the state and returns an optional effect. - /// - content: A view builder that creates the content of the view. - /// - /// ## Example: /// ```swift /// EffectView( /// state: $state, @@ -118,12 +105,19 @@ public struct EffectView< /// } /// .id(env.id) /// ``` + /// + /// - Parameters: + /// - state: A `Binding` to the view's state, owned by the caller. + /// - initialEvent: An optional event sent when the view first appears. + /// - initialEnv: The environment captured for this view's lifetime. + /// - update: Mutates state and returns an optional ``Effect``. + /// - content: Builds the view from current state and an ``Input`` handle. public init( state: Binding, initialEvent: Event? = nil, initialEnv: Env, - update: @escaping (inout State, Event) -> Effect?, - @ViewBuilder content: @escaping (State, Input) -> Content + update: @escaping (inout State, Event) -> Effect?, + @ViewBuilder content: @escaping (State, Input) -> Content ) { self.state = state self.initialEvent = initialEvent @@ -151,7 +145,7 @@ public struct EffectView< let stateBinding = self.state let env = self.env let update = self.update - let send = { @MainActor @Sendable (event: Event, input: Input, continuation: CheckedContinuation?) in + let send = { @MainActor @Sendable (event: Event, input: Input, continuation: CheckedContinuation?) in Self.compute( event: event, continuation: continuation, @@ -171,12 +165,12 @@ public struct EffectView< private static func compute( event: Event, - continuation: CheckedContinuation?, + continuation: CheckedContinuation?, state: Binding, effectManager: EffectManager, - input: Input, + input: Input, env: Env, - update: (inout State, Event) -> Effect? + update: (inout State, Event) -> Effect? ) { var nextEvent: Event? = event var cont = continuation @@ -191,7 +185,7 @@ public struct EffectView< env: env ) } else { - cont?.resume() + cont?.resume(returning: nil) cont = nil } } @@ -199,20 +193,20 @@ public struct EffectView< } private static func executeEffect( - _ effect: Effect, - continuation: CheckedContinuation?, + _ effect: Effect, + continuation: CheckedContinuation?, effectManager: EffectManager, - input: Input, + input: Input, env: Env - ) -> (Event?, CheckedContinuation?) { + ) -> (Event?, CheckedContinuation?) { switch effect { case .task(name: let name, priority: let priority, operation: let operation): effectManager.add( name: name, priority: priority, operation: { - await operation(input, env) - continuation?.resume() + let output = await operation(input, env) + continuation?.resume(returning: output) } ) return (nil, nil) @@ -223,19 +217,19 @@ public struct EffectView< case .action(action: let action): let event = action(env) if event == nil { - continuation?.resume() + continuation?.resume(returning: nil) return (nil, nil) } return (event, continuation) case .cancel(let name): effectManager.cancel(name: name) - continuation?.resume() + continuation?.resume(returning: nil) return (nil, nil) case .sequence(let effects): guard let last = effects.last else { - continuation?.resume() + continuation?.resume(returning: nil) return (nil, nil) } for effect in effects.dropLast() { @@ -248,38 +242,31 @@ public struct EffectView< extension EffectView where Env == Void { - /// Creates an `EffectView` and captures `update` for the lifetime of this view identity. - /// - /// The `update` value is captured once when the view appears the first time. - /// Later changes to `update` are intentionally ignored. - /// - /// `initialEvent`, and `update` values are captured once when the view appears the first time. - /// Later changes to `initialEnv` or `update` are intentionally ignored to avoid mid-flight dependency - /// changes during running effects. To restart with new dependencies, recreate the view identity with - /// `.id(...)`. + /// Creates an effect-managed view with no external dependencies. + /// + /// `initialEvent` and `update` are captured once when the view appears for + /// the first time. Later changes to `update` are intentionally ignored. + /// To reset the view, recreate its identity with `.id(...)`. /// - /// - Parameters: - /// - state: A `Binding` to the view's state, owned by the caller. - /// - initialEvent: An optional initial event to send when the view appears for the first time. - /// - update: A function that updates the state and returns an optional effect. - /// - content: A view builder that creates the content of the view. - /// - /// ## Example: /// ```swift /// EffectView( /// state: $state, - /// initialEnv: env, /// update: Self.update /// ) { state, send in /// Button("Start") { send(.start) } /// } - /// .id(env.id) /// ``` + /// + /// - Parameters: + /// - state: A `Binding` to the view's state, owned by the caller. + /// - initialEvent: An optional event sent when the view first appears. + /// - update: Mutates state and returns an optional ``Effect``. + /// - content: Builds the view from current state and an ``Input`` handle. public init( state: Binding, initialEvent: Event? = nil, - update: @escaping (inout State, Event) -> Effect?, - @ViewBuilder content: @escaping (State, Input) -> Content + update: @escaping (inout State, Event) -> Effect?, + @ViewBuilder content: @escaping (State, Input) -> Content ) { self.state = state self.initialEvent = initialEvent diff --git a/Sources/EffectView/EnvReader.swift b/Sources/EffectView/EnvReader.swift index 04ad4c4..e590cf4 100644 --- a/Sources/EffectView/EnvReader.swift +++ b/Sources/EffectView/EnvReader.swift @@ -1,22 +1,36 @@ import SwiftUI -/// `EnvReader` +/// A view that reads a SwiftUI environment value and +/// passes it to a child view builder. /// -/// The `EnvReader` provides a way to conveniently read an environment value which can be -/// used as a parameter of the initialiser of a child view. -/// -/// ## Usage +/// Use this when you need to forward an environment +/// value as a constructor argument to a child view — +/// a pattern `@Environment` properties alone cannot +/// express inside a closure. /// /// ```swift /// EnvReader(\.myEnv) { env in /// MyView(value: env.value) /// } /// ``` +/// +/// ### Generic parameters +/// +/// - `Env`: The environment value type to read. +/// - `Content`: The view produced by the content closure. public struct EnvReader: View { @Environment private var env: Env private let content: (Env) -> Content + /// Creates an `EnvReader` that reads `keyPath` and + /// passes the resulting value to `content`. + /// + /// - Parameters: + /// - keyPath: A key path into `EnvironmentValues` + /// identifying the value to read. + /// - content: A view builder that receives the + /// environment value. public init( _ keyPath: KeyPath, @ViewBuilder content: @escaping (Env) -> Content diff --git a/Sources/EffectView/Input.swift b/Sources/EffectView/Input.swift index ef02773..8e6811d 100644 --- a/Sources/EffectView/Input.swift +++ b/Sources/EffectView/Input.swift @@ -1,37 +1,72 @@ -/// A lightweight, `Sendable` handle for dispatching events into the FSM + EffectManager. +/// A `Sendable` handle for dispatching events into the effect engine. /// -/// `Input` provides three dispatch strategies with different synchronisation semantics: -/// - ``send(_:)`` — synchronous, fire-and-forget; must already be on the `@MainActor`. -/// - ``enqueue(_:)`` — schedules the event on the `@MainActor` without awaiting it; safe to call from any isolation. -/// - ``perform(_:)`` — suspends the caller until the event has been fully processed. +/// `Input` provides three dispatch strategies with different semantics: +/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`. +/// - ``enqueue(_:)`` — fire-and-forget; safe from any isolation. +/// - ``request(_:)`` — suspends the caller, returning `Output?`. /// -/// ## Isolation and lifetime safety -/// All state mutations run on the `@MainActor`, which is a global, app-lifetime executor. -/// Because the `@MainActor` is never cancelled or destroyed, ``perform(_:)`` is guaranteed -/// to resume its continuation on every code path — no `withTaskCancellationHandler` -/// bookkeeping is required. +/// ### Isolation and lifetime safety /// -/// If the calling `Task` is cancelled while awaiting ``perform(_:)``, the suspension -/// continues until the event is processed; Swift does not automatically resume the -/// continuation on cancellation. This is safe here precisely because the `@MainActor` -/// always completes its work. -public struct Input: Sendable { +/// All state mutations run on the `@MainActor`, a global, app-lifetime +/// executor. Because the `@MainActor` is never destroyed, ``request(_:)`` +/// is guaranteed to resume its continuation on every code path — no +/// `withTaskCancellationHandler` bookkeeping is required. +/// +/// If the calling `Task` is cancelled while awaiting ``request(_:)``, +/// the suspension continues until the event is processed. Swift does not +/// automatically resume continuations on cancellation; this is safe +/// because the `@MainActor` always completes its work. +/// +/// ### 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: Sendable { - init(send: @escaping @MainActor @Sendable (Event, Input, CheckedContinuation?) -> Void) { + init(send: @escaping @MainActor @Sendable (Event, Input, CheckedContinuation?) -> Void) { self._send = send } - private var _send: @Sendable @MainActor (Event, Input, CheckedContinuation?) -> Void + private var _send: @Sendable @MainActor (Event, Input, CheckedContinuation?) -> Void - /// Sends `event` synchronously. The caller must already be running on the `@MainActor`. + /// Dispatches `event` synchronously on the `@MainActor`. + /// + /// Use `send` when you are already running on the `@MainActor` 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 ``enqueue(_:)`` instead. + /// + /// - Warning: Because `send` unwinds `.action` chains synchronously on the `@MainActor`, + /// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — + /// will loop forever and hang the main thread. ``enqueue(_:)`` and ``request(_:)`` are + /// immune because each re-entry is scheduled as a new task, yielding control between iterations. @MainActor public func send(_ event: Event) { _send(event, self, nil) } - /// Schedules `event` to be sent on the `@MainActor` without awaiting its processing. - /// Safe to call from any actor isolation or a non-isolated context. + /// Schedules `event` on the `@MainActor` 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. @inline(__always) public func enqueue(_ event: sending Event) { Task { @MainActor in @@ -39,32 +74,48 @@ public struct Input: Sendable { } } - /// Sends `event` and suspends until the entire resulting effect chain has completed. + /// 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 `perform` returns. + /// before `request` returns. /// /// ``` - /// event → action (→ event → action …) → task ← perform resumes here - /// └─ or nil effect / .cancel + /// event → [.action chain] → terminal effect + /// ├─ .task → Output? + /// ├─ .cancel → nil + /// └─ nil → nil /// ``` /// /// The caller hops to the `@MainActor` for the duration of the call. Because the /// `@MainActor` is a global, app-lifetime executor, the continuation is always /// resumed — no cancellation handler is needed. /// - /// > Note: If the calling `Task` is cancelled while suspended, `perform` continues - /// > to wait until the effect chain settles naturally. + /// - Note: If the calling `Task` is cancelled while suspended, + /// `request` continues to wait until the effect chain settles. + /// + /// For usage patterns including `.refreshable`, `task(id:)`, and testing, + /// see . + @discardableResult @MainActor - public func perform(_ event: sending Event) async -> Void { + public func request(_ event: sending Event) async -> Output? { await withCheckedContinuation { continuation in self._send(event, self, continuation) } } + /// Sends `event` and suspends until the entire resulting effect chain has completed. + /// + /// - Note: Renamed to ``request(_:)``. Use `request` for new code. + @available(*, deprecated, renamed: "request(_:)") + @MainActor + public func perform(_ event: sending Event) async -> Void { + await request(event) + } + /// Convenience call-as-function syntax for ``enqueue(_:)``. @inline(__always) public func callAsFunction(_ event: sending Event) { diff --git a/Sources/EffectView/ObservationHelpers.swift b/Sources/EffectView/ObservationHelpers.swift index 88932f1..8d9ed2e 100644 --- a/Sources/EffectView/ObservationHelpers.swift +++ b/Sources/EffectView/ObservationHelpers.swift @@ -4,21 +4,22 @@ import Observation extension Effect { - /// Observes a key path on an `@Observable` object resolved from the environment, - /// dispatching events as the value changes. + /// Observes a key path on an `@Observable` object resolved from the environment. /// /// The handler is invoked with the **initial value** immediately, then again on every /// subsequent change, until the task is cancelled or the object is deallocated. /// /// The object is resolved from the environment inside the task, so the effect captures - /// only a key path rather than the object itself. Use ``Input/perform(_:)`` in the + /// only a key path rather than the object itself. Use ``Input/request(_:)`` in the /// handler so the loop waits for the view to settle before advancing: /// /// ```swift /// // update: /// case .start: - /// return .observe(\.store, keyPath: \.count) { input, count in - /// await input.perform(.countChanged(count)) + /// return .observe( + /// \.store, keyPath: \.count + /// ) { input, count in + /// await input.request(.countChanged(count)) /// } /// ``` /// @@ -32,7 +33,7 @@ extension Effect { /// - name: Optional name for the underlying task. Defaults to `"observe"`. /// - priority: Optional `TaskPriority` for the underlying task. /// - handler: Called with `input` and the current value on the initial read and on - /// every subsequent change. `async` — use `await input.perform(…)` to wait for the + /// every subsequent change. `async` — use `await input.request(…)` to wait for the /// view to settle before the next observation cycle. @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) public static func observe( @@ -40,7 +41,7 @@ extension Effect { keyPath: KeyPath, name: String? = "observe", priority: TaskPriority? = nil, - handler: @escaping @MainActor @Sendable (Input, Value) async -> Void + handler: @escaping @MainActor @Sendable (Input, Value) async -> Void ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable { @@ -51,24 +52,27 @@ extension Effect { await observeKeyPath(object, keyPath: box.keyPath) { value in await handler(input, value) } + return nil } } - /// Observes a key path on an `@Observable` object and dispatches events as values change. + /// Observes a key path on a directly provided `@Observable` object. /// /// The handler is invoked with the **initial value** immediately, then again on every /// subsequent change, until the task is cancelled or the object is deallocated. /// /// The `input` parameter gives the handler the same three dispatch strategies - /// (``Input/enqueue(_:)``, ``Input/send(_:)``, ``Input/perform(_:)``) available in any - /// other effect. For observation you will typically want ``Input/perform(_:)`` so the loop + /// (``Input/enqueue(_:)``, ``Input/send(_:)``, ``Input/request(_:)``) available in any + /// other effect. For observation you will typically want ``Input/request(_:)`` so the loop /// waits for the EffectView to process each change before advancing to the next one: /// /// ```swift /// // update: /// case .storeReceived(let store): - /// return .observe(store, keyPath: \.count) { input, count in - /// await input.perform(.countChanged(count)) + /// return .observe( + /// store, keyPath: \.count + /// ) { input, count in + /// await input.request(.countChanged(count)) /// } /// ``` /// @@ -83,7 +87,7 @@ extension Effect { /// - name: Optional name for the underlying task. Defaults to `"observe"`. /// - priority: Optional `TaskPriority` for the underlying task. /// - handler: Called with `input` and the current value on the initial read and on - /// every subsequent change. `async` — use `await input.perform(…)` to wait for the + /// every subsequent change. `async` — use `await input.request(…)` to wait for the /// view to settle before the next observation cycle. @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) public static func observe( @@ -91,7 +95,7 @@ extension Effect { keyPath: KeyPath, name: String? = "observe", priority: TaskPriority? = nil, - handler: @escaping @MainActor @Sendable (Input, Value) async -> Void + handler: @escaping @MainActor @Sendable (Input, Value) async -> Void ) -> Self where Object: Observable & AnyObject & Sendable, Value: Sendable { @@ -100,6 +104,7 @@ extension Effect { await observeKeyPath(object, keyPath: box.keyPath) { value in await handler(input, value) } + return nil } } @@ -117,12 +122,9 @@ private struct SendableKeyPath: @unchecked Sendable { let keyPath: KeyPath } -/// Observes a key path on an `@Observable` object, calling `handler` with each new value -/// until the current task is cancelled or `object` is deallocated. -/// -/// On macOS 26+ / iOS 26+ uses `Observations.untilFinished` — a structured `AsyncSequence` -/// with cooperative cancellation. On earlier OS versions falls back to -/// `withObservationTracking` with recursive re-registration. +/// Observes a key path on an `@Observable` object, calling `handler` +/// with each new value until the task is cancelled or `object` is +/// deallocated. @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) @MainActor public func observeKeyPath( @@ -150,8 +152,8 @@ public func observeKeyPath( /// /// `onChange` fires *before* the new value is committed and on an arbitrary thread, so a /// child `Task` hops to `@MainActor` to read the settled value. One unstructured task may -/// outlive cancellation by a single iteration — this is benign because `input.perform` on -/// a completed `EffectView` is a no-op. +/// outlive cancellation by a single iteration — this is benign because +/// `input.request` on a completed `EffectView` is a no-op. @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) @MainActor private func _observeKeyPath_legacy( diff --git a/Tests/EffectViewTests/EffectViewTests.swift b/Tests/EffectViewTests/EffectViewTests.swift index 1d6eebb..b5ff07f 100644 --- a/Tests/EffectViewTests/EffectViewTests.swift +++ b/Tests/EffectViewTests/EffectViewTests.swift @@ -106,7 +106,7 @@ struct EffectViewTests { var appearCount = 0 let view = TestView(initialState: State()) { binding in - EffectView(state: binding, update: { _, _ -> Effect? in nil }) { _, _ in + EffectView(state: binding, update: { _, _ -> Effect? in nil }) { _, _ in Color.clear.onAppear { appearCount += 1 } } } @@ -122,7 +122,7 @@ struct EffectViewTests { var capturedLabel: String? let view = TestView(initialState: State(label: "custom")) { binding in - EffectView(state: binding, update: { _, _ -> Effect? in nil }) { state, _ in + EffectView(state: binding, update: { _, _ -> Effect? in nil }) { state, _ in Color.clear.onAppear { capturedLabel = state.label } } } @@ -138,7 +138,7 @@ struct EffectViewTests { struct State: Equatable { var count = 0 } enum Event: Sendable { case increment } - var capturedInput: Input? + var capturedInput: Input? var observedValues: [Int] = [] let expectation = Expectation() @@ -147,7 +147,7 @@ struct EffectViewTests { let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } + update: { state, _ -> Effect? in state.count += 1; return nil } ) { state, input in Text("\(state.count)") .onAppear { @@ -177,14 +177,14 @@ struct EffectViewTests { class RenderCounter: @unchecked Sendable { var count = 0 } let counter = RenderCounter() let expectation = Expectation() - var capturedInput: Input? + var capturedInput: Input? let timeout: UInt64 = 5_000_000_000 let view = TestView(initialState: State.off) { binding in EffectView( state: binding, - update: { state, _ -> Effect? in state = (state == .off ? .on : .off); return nil } + update: { state, _ -> Effect? in state = (state == .off ? .on : .off); return nil } ) { state, input in Text(state == .on ? "on" : "off") .onAppear { @@ -223,7 +223,7 @@ struct EffectViewTests { EffectView( state: binding, initialEvent: .start, - update: { _, event -> Effect? in + update: { _, event -> Effect? in // Note: update with the initial event will be called before // onAppear will be called log.events.append(event) @@ -240,18 +240,18 @@ struct EffectViewTests { cleanup(window) } - // MARK: - perform + // MARK: - request - @Test func performSuspendsUntilUpdateCompletes() async throws { + @Test func requestSuspendsUntilUpdateCompletes() async throws { struct State: Equatable { var count = 0 } enum Event: Sendable { case increment } - var capturedInput: Input? + var capturedInput: Input? let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } + update: { state, _ -> Effect? in state.count += 1; return nil } ) { _, input in Color.clear.onAppear { capturedInput = input @@ -262,11 +262,11 @@ struct EffectViewTests { let (_, window) = try await embedInWindowAndMakeKey(view) guard let input = capturedInput else { Issue.record("Input not captured"); return } - // Each perform() suspends until the update loop has processed the event. - // Three sequential performs must complete without deadlock or timeout. - await input.perform(.increment) - await input.perform(.increment) - await input.perform(.increment) + // Each request() suspends until the update loop has processed the event. + // Three sequential requests must complete without deadlock or timeout. + await input.request(.increment) + await input.request(.increment) + await input.request(.increment) cleanup(window) } @@ -277,7 +277,7 @@ struct EffectViewTests { class LogCapture: @unchecked Sendable { var entries: [Int] = [] } let captured = LogCapture() - var capturedInput: Input? + var capturedInput: Input? let doneExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 @@ -285,7 +285,7 @@ struct EffectViewTests { let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, event -> Effect? in + update: { state, event -> Effect? in if case .record(let n) = event { state.log.append(n) } return nil } @@ -304,20 +304,58 @@ struct EffectViewTests { let (_, window) = try await embedInWindowAndMakeKey(view) guard let input = capturedInput else { Issue.record("Input not captured"); return } - // perform() guarantees each update completes before the next event is sent. - for i in 1...5 { await input.perform(.record(i)) } + // request() guarantees each update completes before the next event is sent. + for i in 1...5 { await input.request(.record(i)) } try await doneExpectation.await(nanoseconds: timeout) #expect(captured.entries == [1, 2, 3, 4, 5]) cleanup(window) } + @Test func requestReturnsOutputFromTaskClosure() async throws { + struct State: Equatable { var value: String = "" } + enum Event: Sendable { case load, loaded(String) } + typealias Output = String + + var capturedInput: Input? + + let view = TestView(initialState: State()) { binding in + EffectView( + state: binding, + update: { (state, event) -> Effect? in + switch event { + case .load: + return .request(name: "load") { input, _ in + // Simulate async work, fire a completion event to update state, + // then return the output value directly from the task closure. + let result = "hello" + await input.request(.loaded(result)) // drives state; return discarded + return result // this becomes the Output? + } + case .loaded(let v): + state.value = v + return nil + } + } + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } + + let (_, window) = try await embedInWindowAndMakeKey(view) + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let output = await input.request(.load) + #expect(output == "hello") + cleanup(window) + } + // MARK: - Effects @Test func taskEffectRunsAndMutatesState() async throws { struct State: Equatable { var loaded = false } enum Event: Sendable { case load, didLoad } - var capturedInput: Input? + var capturedInput: Input? let loadedExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 @@ -325,7 +363,7 @@ struct EffectViewTests { let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, event -> Effect? in + update: { state, event -> Effect? in switch event { case .load: return .task(name: "fetch") { input, _ in input.enqueue(.didLoad) } @@ -356,7 +394,7 @@ struct EffectViewTests { class TickCounter: @unchecked Sendable { var count = 0 } let tickCounter = TickCounter() - var capturedInput: Input? + var capturedInput: Input? let twoTicksExpectation = Expectation(minFulfillCount: 2) let stoppedExpectation = Expectation() @@ -365,7 +403,7 @@ struct EffectViewTests { let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, event -> Effect? in + update: { state, event -> Effect? in switch event { case .start: state.running = true @@ -421,14 +459,14 @@ struct EffectViewTests { struct State: Equatable { var phase = 0 } enum Event: Sendable { case begin, step, done } - var capturedInput: Input? + var capturedInput: Input? let readyExpectation = Expectation() let doneExpectation = Expectation() let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, event -> Effect? in + update: { state, event -> Effect? in switch event { case .begin: state.phase = 1; return .action { _ in .step } case .step: state.phase = 2; return .action { _ in .done } @@ -450,8 +488,8 @@ struct EffectViewTests { let (_, window) = try await embedInWindowAndMakeKey(view) try await readyExpectation.await(nanoseconds: 5_000_000_000) - // perform() awaits the entire synchronous chain: begin → step → done. - await capturedInput?.perform(.begin) + // request() awaits the entire synchronous chain: begin → step → done. + await capturedInput?.request(.begin) try await doneExpectation.await(nanoseconds: 5_000_000_000) cleanup(window) } @@ -460,7 +498,7 @@ struct EffectViewTests { struct State: Equatable { var ticks = 0 } enum Event: Sendable { case startFirst, refresh, tick } - var capturedInput: Input? + var capturedInput: Input? let tickExpectation = Expectation() let cancelExpectation = Expectation() @@ -469,7 +507,7 @@ struct EffectViewTests { let view = TestView(initialState: State()) { binding in EffectView( state: binding, - update: { (state, event) -> Effect? in + update: { (state, event) -> Effect? in switch event { case .startFirst: // Long-running task that never ticks on its own. @@ -517,7 +555,7 @@ struct EffectViewTests { struct State: Equatable { var count = 0 } enum Event: Sendable { case increment } - var capturedInput: Input? + var capturedInput: Input? let resetExpectation = Expectation() var countsOnAppear: [Int] = [] @@ -527,7 +565,7 @@ struct EffectViewTests { TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } + update: { state, _ -> Effect? in state.count += 1; return nil } ) { _, input in Color.clear.onAppear { capturedInput = input @@ -538,15 +576,15 @@ struct EffectViewTests { guard let input = capturedInput else { Issue.record("Input not captured"); return } - await input.perform(.increment) - await input.perform(.increment) + await input.request(.increment) + await input.request(.increment) // Replace the root view with a fresh instance at initial state. hostingController.rootView = AnyView( TestView(initialState: State()) { binding in EffectView( state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } + update: { state, _ -> Effect? in state.count += 1; return nil } ) { state, _ in Color.clear.onAppear { countsOnAppear.append(state.count) @@ -568,7 +606,7 @@ struct EffectViewTests { enum Event: Sendable { case fetch, loaded(String) } struct Env: Sendable { var value: String } - var capturedInput: Input? + var capturedInput: Input? let loadedExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 @@ -577,7 +615,7 @@ struct EffectViewTests { EffectView( state: binding, initialEnv: Env(value: "hello from env"), - update: { state, event -> Effect? in + update: { state, event -> Effect? in switch event { case .fetch: return .task(name: "fetch") { input, env in @@ -641,7 +679,7 @@ private enum CounterEvent: Equatable, Sendable { private func counterUpdate( state: inout CounterState, event: CounterEvent -) -> Effect? { +) -> Effect? { switch event { case .increment: state.count += 1 @@ -690,7 +728,7 @@ private struct LoadFetchError: Error, LocalizedError { private func loaderUpdate( state: inout LoaderState, event: LoaderEvent -) -> Effect? { +) -> Effect? { switch event { case .load: state.isLoading = true @@ -801,7 +839,7 @@ struct EffectTypeTests { @Test func actionEffectInvokesClosureAndReturnsEvent() { enum Ev: Equatable, Sendable { case a, b } - let effect = Effect.action { _ in .b } + let effect = Effect.action { _ in .b } guard case .action(let run) = effect else { Issue.record("Expected .action") return @@ -811,7 +849,7 @@ struct EffectTypeTests { @Test func actionEffectCanReturnNil() { enum Ev: Equatable, Sendable { case a } - let effect = Effect.action { _ in nil } + let effect = Effect.action { _ in nil } guard case .action(let run) = effect else { Issue.record("Expected .action") return @@ -821,7 +859,7 @@ struct EffectTypeTests { @Test func sequenceContainsOrderedEffects() { enum Ev: Equatable, Sendable { case done } - let effect = Effect.sequence([ + let effect = Effect.sequence([ .cancel("old"), .task(name: "new") { _, _ in } ]) @@ -859,7 +897,7 @@ struct TaskOperationTests { } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } + let input = Input { [spy] event, _, _ in spy.received.append(event) } await operation(input, LoaderEnv(fetch: { ["X", "Y"] })) await Task.yield() @@ -874,7 +912,7 @@ struct TaskOperationTests { } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } + let input = Input { [spy] event, _, _ in spy.received.append(event) } await operation(input, LoaderEnv(fetch: { throw LoadFetchError(message: "timed out") })) await Task.yield() @@ -889,7 +927,7 @@ struct TaskOperationTests { } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } + let input = Input { [spy] event, _, _ in spy.received.append(event) } await operation(input, ()) await Task.yield()