diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme new file mode 100644 index 0000000..bda2159 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md index fd6190c..328701f 100644 --- a/Documentation/ArchitecturalComparison.md +++ b/Documentation/ArchitecturalComparison.md @@ -107,7 +107,7 @@ EffectView translates the Elm/GenServer model into idiomatic SwiftUI — using ` **State model:** Three kinds of state are structurally distinct: - **Ephemeral state** (`ViewState`) — a plain value type owned by `@State`. Lives and dies with the view's identity. Nothing outside the view can read or write it. -- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a named slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API. +- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a specific slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API. - **Persistent state** — always external. Effects read from or write to persistence, then translate results back into events. `update` never sees storage directly. The read/write relationship with shared state is asymmetric by construction: @@ -119,19 +119,19 @@ ViewState ──(user action)──▶ update ──▶ effect ──▶ store.s **Effect model:** `update` returns an `Effect` value — a description, never an execution. The library executes it. `update` is synchronous, has no `async` annotation, and cannot perform work directly. The same event on the same state always produces the same `Effect` description. -**Task lifecycle:** Tasks are created by returning `.task(name:)` from `update`. They are cancelled by returning `.cancel(name)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. +**Task lifecycle:** Tasks are created by returning `.run(id:)`, `.request(id:)`, or `.task(id:)` from `update`. They are cancelled by returning `.cancel(...)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. -A named task is automatically cancelled and replaced if `update` returns a new `.task` with the same name before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: +A task is automatically cancelled and replaced if `update` returns new work with the same identifier before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: ```swift // update: case .queryChanged(let q): state.query = q - return .task(name: "search") { input, env in // cancels any prior "search" task + return .run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) - await input.perform(.resultsLoaded(results)) + try? input.post(.resultsLoaded(results)) } ``` @@ -139,22 +139,24 @@ case .queryChanged(let q): | Method | Semantics | Use when | |---|---|---| -| `input(.event)` | Fire-and-forget. Enqueues on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget | -| `await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work | -| `await input.perform(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure | +| `try input.post(.event)` | Fire-and-forget. Schedules on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget | +| `try await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work | +| `try await input.request(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure | -`perform` is the mechanism that makes SwiftUI's `.refreshable` work naturally: +`request` is the mechanism that makes SwiftUI's `.refreshable` work naturally: ```swift .refreshable { - await input.perform(.refresh) // spinner shown until the full load cycle completes + try? await input.request(.refresh) + // spinner shown until the full load cycle completes } ``` And it provides natural backpressure in observation loops — the loop does not advance to wait for the next store change until the view has fully processed the current one: ```swift -await input.perform(.storeChanged(newCount: count)) // next observation cycle waits here +try? await input.request(.storeChanged(newCount: count)) +// next observation cycle waits here ``` No rate-limiting code, no semaphores. The dispatch semantic *is* the backpressure mechanism. @@ -168,6 +170,28 @@ XCTAssertEqual(state.isLoading, true) // inspect the returned Effect description if needed ``` +**Component API surface:** An EffectView-based component exposes exactly three concepts to the outside world: + +| Concept | Role | +|---|---| +| `Event` | What you can *send* to it | +| `Output` | What you can *receive* from it (via `try await input.request(_:)`) | +| `State` | What it *renders* (optionally exposed) | + +Everything else — running tasks, `Env`, intermediate async types, internal models — is private to the component. The `Output?` value is not the return value of an async operation; it is what the transition function decides to hand back after the machine has processed the effect chain. The async work inside a task is an implementation detail the caller never sees. + +`State` has a natural second layer of privacy: the `Binding` is held by a `private @State` in the enclosing SwiftUI view. Ancestor views see only what is rendered — they never hold the binding. In practice, the visible API of a component from the outside is just `Event` and `Output`: + +``` +Ancestor view │ SwiftUI view (owner) │ EffectView internals +───────────────────────┼──────────────────────────┼───────────────────────── +sees rendered UI only │ State (private @State) │ tasks (private) +can send Event │ Event │ Env (private) +awaits Output? │ Output │ intermediate types +``` + +This is a stronger encapsulation boundary than TCA's store, which is deliberately transparent: any code holding a `Store` reference can observe the entire state tree. EffectView components behave more like actors — they receive messages, produce typed responses, and keep everything else behind a wall. + --- ## Summary @@ -181,7 +205,7 @@ XCTAssertEqual(state.isLoading, true) | **Task creation** | Anywhere | Middleware | `Effect.run` | Runtime | Spawn | `update` return value | | **Task cancellation** | Manual handle | Dispatch cancel action | `CancelID` action | Runtime subscription diff | Process termination | `update` return value | | **Task scope** | ViewModel lifetime | App lifetime | Store lifetime | Runtime | Process tree | View identity | -| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `enqueue` / `send` / `perform` | +| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `post` / `send` / `request` | | **Test surface** | Full class construction | Reducer pure function | `TestStore` harness | Pure `update` function | Process message passing | Static pure function | The common thread in the well-designed patterns (Elm, GenServer, TCA, EffectView) is the same: a single authoritative transition function that owns all state mutations and returns effect descriptions. The differences are in scope (global vs. local), dispatch semantics, task lifecycle management, and how much framework ceremony is required to express the pattern. diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md index 460ab37..90d1959 100644 --- a/Documentation/BridgingEventDrivenAndImperative.md +++ b/Documentation/BridgingEventDrivenAndImperative.md @@ -19,7 +19,8 @@ spinner disappears before the data arrives: ```swift .refreshable { - send(.refresh) // returns instantly; spinner stops too early + try? input.post(.refresh) + // returns instantly; spinner stops too early } ``` @@ -32,11 +33,12 @@ The same issue arises for any SwiftUI feature that awaits an async closure: ## The solution: `request(_:)` `Input.request(_:)` suspends the caller until the entire resulting effect chain -has settled and returns an optional `Output` value: +has settled and returns an optional `Output` value, or throws if the runtime +cannot accept or complete the request: ```swift .refreshable { - await input.request(.refresh) + try? await input.request(.refresh) // resumes only when .refresh has been // fully processed and the effect settled } @@ -77,7 +79,7 @@ consequence of that event. ```swift .refreshable { - await input.request(.refresh) + try? await input.request(.refresh) } ``` @@ -92,7 +94,7 @@ dismissing: ```swift Button("Save") { Task { - let saved = await input.request(.save) + let saved = try? await input.request(.save) if saved != nil { dismiss() } } } @@ -106,7 +108,7 @@ settled: ```swift .task(id: appPhase) { if appPhase == .active { - await input.request(.resumeIfNeeded) + try? await input.request(.resumeIfNeeded) } } ``` @@ -117,7 +119,7 @@ settled: or polling required: ```swift -let result = await input.request(.load) +let result = try await input.request(.load) XCTAssertEqual(state.items.count, 3) ``` @@ -127,13 +129,13 @@ XCTAssertEqual(state.items.count, 3) | Approach | Stays suspended? | Returns a value? | Always resumes? | |---|---|---|---| -| `send(.refresh)` | No | — | — | +| `try? input.post(.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 | +| `try await input.request(.refresh)` | Yes | Yes (`Output?`) | Yes | ### TCA: `StoreTask.finish()` @@ -182,7 +184,7 @@ As a result, bridging to `.refreshable` requires a workaround: a state flag ```swift // ImmutableData — workaround required .refreshable { - dispatcher.dispatch(action: .refresh) + try? dispatcher.dispatch(action: .refresh) // must poll or observe isRefreshing to know // when to let the closure return } @@ -211,5 +213,5 @@ 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" +`post`) 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/Documentation/CorrectByConstruction.md b/Documentation/CorrectByConstruction.md index 612c210..7802337 100644 --- a/Documentation/CorrectByConstruction.md +++ b/Documentation/CorrectByConstruction.md @@ -88,19 +88,22 @@ enum SearchEvent { The update function is a `switch` over state and event: ```swift -func update(state: inout SearchState, event: SearchEvent) -> Effect? { +static func update( + _ state: inout SearchState, + event: SearchEvent +) -> Effect? { switch (state, event) { case (_, .searchTapped(let query)): state = .loading(query: query) return .sequence([ .cancel("search"), - .task(name: "search") { input, env in + .run(id: "search") { input, env in do { let movies = try await env.search(query: query) - input.enqueue(.resultsReceived(movies)) + try? input.post(.resultsReceived(movies)) } catch { - input.enqueue(.requestFailed(error.localizedDescription)) + try? input.post(.requestFailed(error.localizedDescription)) } } ]) @@ -160,7 +163,7 @@ Feature: Movie search // Scenario: User starts a search case (_, .searchTapped(let query)): state = .loading(query: query) - return .task(name: "search") { ... } + return .run(id: "search") { ... } // Scenario: Search returns results case (.loading(let query), .resultsReceived(let movies)): @@ -235,7 +238,7 @@ You can also derive a transition table from the test suite and verify it matches The enum state model eliminates category 1 entirely: the Swift type system won't let you represent `isLoading == true && errorMessage != nil` if your states are an enum. -Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `.sequence([.cancel("search"), .task(name: "search") { ... }])` branch and replaces the in-flight task cleanly. +Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `.sequence([.cancel("search"), .run(id: "search") { ... }])` branch and replaces the in-flight task cleanly. Concurrency exists — tasks genuinely run in the background — but concurrency never *touches* state directly. It only delivers events. The update function remains a simple, synchronous function. @@ -264,7 +267,7 @@ The key MVI property is **unidirectional data flow**: state flows down into the | State | Swift enum | Impossible states unrepresentable | | Transitions | `update` function | Pure, synchronous, compiler-verified | | Side effects | `Effect` return values | Declarative descriptions, not execution | -| Async work | Task closures in `Effect` | Isolated, named, cancellable | +| Async work | Task closures in `Effect` | Isolated, identified, cancellable | | Rendering | SwiftUI `Content` closure | Reads state only, fires events only | The update function does one thing: given a state and an event, decide what the next state is and what work to trigger. Because it is pure and synchronous, it is trivially testable, straightforwardly readable, and directly traceable to requirements. Concurrency is real, but it is confined to the edges — it delivers events, it doesn't own state. diff --git a/Documentation/Recipes.md b/Documentation/Recipes.md new file mode 100644 index 0000000..139c59b --- /dev/null +++ b/Documentation/Recipes.md @@ -0,0 +1,117 @@ +# Recipes + +Short, practical snippets for common EffectView patterns. + +## Post an event from the view + +Use `post` when the view should trigger work and continue immediately. + +```swift +Button("Retry") { + try? input.post(.retryTapped) +} + +.onChange(of: query) { + try? input.post(.queryChanged($0)) +} +``` + +`post` is fire-and-forget. It schedules the event and returns immediately. + +## Wait until a flow has settled + +Use `request` when the caller should wait until the whole triggered flow is done. + +```swift +List(state.items, id: \.id) { item in + Text(item.title) +} +.refreshable { + try? await input.request(.refresh) +} +``` + +This is the right choice for `.refreshable`, because SwiftUI keeps the spinner visible while the request is still in flight. + +## Debounce or restart search automatically + +Give the task an identifier. Starting the same identifier again replaces the older work. + +```swift +case .queryChanged(let query): + state.query = query + state.isLoading = true + + return .run(id: "search") { input, env in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + + do { + let results = try await env.search(query) + try? input.post(.resultsLoaded(results)) + } catch { + try? input.post(.searchFailed(error.localizedDescription)) + } + } +``` + +That removes the need to store and cancel `Task` handles manually. + +## Cancel stale work before starting new work + +Use `sequence` when one step should happen before another. + +```swift +case .refresh: + state.isRefreshing = true + return .sequence([ + .cancel("load"), + .run(id: "refresh") { input, env in + do { + let items = try await env.loadItems() + try? input.post(.loaded(items)) + } catch { + try? input.post(.loadFailed(error.localizedDescription)) + } + } + ]) +``` + +This is a concise way to say: stop the old work first, then start the replacement. + +## Mirror an external `@Observable` value into feature state + +Use `observe` when a feature should react to changes from an external store or model. + +```swift +struct Env: Sendable { + var store: CounterStore +} + +case .startObserving: + return .observe(\.store, keyPath: \.count) { input, count in + try? await input.request(.countChanged(count)) + } + +case .countChanged(let count): + state.count = count + return nil +``` + +The observation task emits the initial value immediately, then sends updates as the observed value changes. + +## Keep logic easy to test + +Because the decision-making stays in `update`, you can test the feature by driving state and events directly. + +```swift +var state = SearchFeature.State() + +let effect = SearchFeature.update(&state, event: .queryChanged("milk")) + +XCTAssertEqual(state.query, "milk") +XCTAssertTrue(state.isLoading) +XCTAssertNotNil(effect) +``` + +The test checks what changed immediately. If needed, separate tests can exercise the returned effect path. \ No newline at end of file diff --git a/Documentation/RuntimeDesign.md b/Documentation/RuntimeDesign.md new file mode 100644 index 0000000..02a5c5f --- /dev/null +++ b/Documentation/RuntimeDesign.md @@ -0,0 +1,345 @@ +# Runtime Design + +This note describes the runtime model behind `EffectView`. + +It is not an API tutorial. The goal is to make the runtime's invariants explicit, explain why the implementation looks the way it does, and preserve the design intent as the library evolves. + +In short, `EffectView` is an event-driven runtime built around a finite state machine model of computation. Events drive state transitions, effects describe operations to perform, and the runtime executes and manages those operations while preserving ordered state reduction. + +At a high level, effects come in two forms: + +- Actions are inline effect steps in the current computation cycle. Unlike tasks, they remain part of the current event chain even when they suspend. +- tasks are managed asynchronous operations; they run outside the current reduction step, may be tracked by logical identifier, and can feed events back into the system later + +Two earlier articles describe adjacent concerns from the public API side: + +- [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) explains why the library needs runtime-managed effects instead of relying on SwiftUI's `.task` modifier. +- [Using Env for dependency injection](UsingEnvForDependencyInjection.md) explains how dependencies are captured and forwarded into effects. + +This document focuses on the runtime itself: event processing, back pressure, cancellation, continuations, and the split between domain work and runtime control. + +--- + +## The problem this runtime solves + +At the feature level, `EffectView` wants a simple contract: + +- state changes only in `update` +- effects are declared, not performed inline +- async work can send events back into the system +- callers can choose how to dispatch an event: post it and return immediately, send it and wait for the current computation cycle, or request it and suspend until the terminal result is available + +That contract becomes significantly harder to uphold once async actions and runtime-managed tasks exist. + +In particular, the runtime must answer these questions: + +1. Can event processing be re-entered while an async action is suspended? +2. What happens when a hard cancellation or fatal runtime error arrives while an event chain is mid-flight? +3. Who owns a request continuation at each phase of execution? +4. How should overlapping callers apply back pressure to the system? +5. Which concerns belong to the transducer state machine, and which belong to the runtime itself? + +The current design answers those questions explicitly rather than implicitly. + +--- + +## Design goals + +The runtime is intentionally designed around the following goals: + +1. Preserve a single ordered mutation path for domain state. +2. Support async effects and async actions without pushing buffering logic into transducer state. +3. Allow callers to use ordinary suspending functions as the back pressure mechanism. +4. Permit immediate runtime interruption while a regular event chain is suspended. +5. Keep the implementation small enough that the invariants are locally understandable. + +These goals are Swift-shaped. They rely on actor isolation, structured concurrency, typed continuations, and `Sendable` boundaries. The same design may not map directly to languages that lack those tools. + +--- + +## The two-path model + +The runtime has two distinct execution paths: + +- `compute(...)` processes regular domain events. +- `control(...)` processes runtime control events. + +This split is fundamental. + +### `compute(...)` + +`compute(...)` is the domain path. It: + +- reads and mutates transducer state +- calls `update` +- executes returned effects +- may suspend while awaiting async actions +- may transfer a request continuation into a managed task + +Because `compute(...)` is the path that can mutate state, it must not be re-entered. + +### `control(...)` + +`control(...)` is the runtime path. It: + +- does not call `update` +- must not mutate transducer storage +- may cancel the runtime or latch a system failure +- may run while a regular `compute(...)` invocation is suspended + +This is not merely acceptable. Once async actions exist, it is the correct model. A hard-stop control path must be able to intercept a suspended event chain immediately. Otherwise shutdown would be delayed until the action returns, which would weaken runtime cancellation semantics substantially. + +--- + +## Core invariants + +The runtime is designed around these invariants: + +1. `compute(...)` is never re-entered. +2. `control(...)` may interleave with a suspended `compute(...)`. +3. `control(...)` must never mutate transducer storage. +4. All regular event entry points pass through the same gate before entering `compute(...)`. +5. After every suspension point inside `compute(...)`, runtime cancellation must be re-checked before more work is committed. +6. A thrown `compute(...)` does not consume the request continuation; the caller remains responsible for resuming or failing it. +7. Shutdown and latched failure state remain centralized in one runtime authority. + +If future changes violate one of these rules, they should be treated as a design change, not as an incidental refactor. + +--- + +## Why `compute(...)` is gated + +Without a gate, an awaited async action yields the system actor, allowing another task to enter the runtime and call `compute(...)` again. That would break the single ordered mutation model. + +The runtime therefore uses a gate in front of `compute(...)`. + +Conceptually, the gate means: + +- if no regular event chain is active, the caller enters immediately +- if a regular event chain is already active, the next caller suspends until admission + +This is a strict back pressure model. + +The runtime does not primarily buffer events. Instead, callers wait. + +### Why not an event buffer? + +An event buffer is viable, but it encodes a different policy: + +- the runtime owns queued events +- overflow policy becomes part of the design +- buffering and scheduling concerns become more prominent than caller suspension + +The current design chooses the opposite center of gravity: + +- the caller owns its event until admitted +- suspension is the back pressure mechanism +- structured concurrency expresses the waiting relationship directly + +In practice, this lets call sites remain simple: they use suspending functions, and back pressure emerges from the language's existing async model rather than from a separate buffering abstraction. + +### Why the gate can stay simple + +The gate itself can stay small because it lives on the system actor. It does not need mutexes or cross-thread synchronization primitives. Its job is only to serialize admission to `compute(...)`. + +Implementation details may evolve, but conceptually the gate is a FIFO of waiting senders or continuations, not a mailbox of events. + +--- + +## Event entry semantics + +The runtime exposes three relevant caller-facing modes: + +- fire-and-forget event submission +- synchronous event submission +- request-style submission with a result continuation + +These modes differ in how much of the event chain the caller waits for, but they all rely on the same underlying serialization rules for regular events. + +### Fire-and-forget + +A fire-and-forget call schedules work and returns immediately. The caller does not wait for `compute(...)` to run. + +This is intentionally the weakest back pressure mode. It is useful, but it is also the deliberate escape hatch: callers can create pending work without themselves awaiting admission. + +### Synchronous send + +A synchronous `send` means: + +- the caller waits for the event chain it started +- the caller does not directly await unrelated managed tasks unless the chain reaches a request-style effect +- the system guarantees that regular event reduction remains serialized + +The important nuance is that "synchronous" here is semantic, not literally non-suspending. If an async action runs inline, `send` may suspend while still preserving single-entry regular event reduction. + +### Request + +A request carries a continuation through the event chain until the chain terminates, transfers the continuation into a managed task, or throws out of `compute(...)`. + +This is the runtime's bridge between event-driven logic and ordinary async/await callers. + +--- + +## Async actions + +Async actions exist to model a bounded awaited step that must remain logically inside the current event chain. + +They are intentionally different from managed tasks. + +An async action: + +- runs inline as part of the current `compute(...)` chain +- does not create its own task identity +- does not participate in task overlap policies like `subscribe` or `switchToLatest` +- may suspend +- must be followed by a cancellation re-check before its result is trusted + +This makes async actions suitable for short prerequisite steps such as: + +- actor bootstrap before later events are allowed to proceed +- establishing an authorization token or capability handle +- awaiting a bounded dependency precondition before the next event can be interpreted correctly + +Async actions are not the right tool for long-running background work, subscriptions, retry loops, or overlapping work that needs runtime-managed identity. Those belong to managed tasks. + +--- + +## Post-suspension cancellation checks + +Because `control(...)` may intercept a suspended `compute(...)`, an awaited async action cannot simply resume and continue as if nothing happened. + +After an async suspension point, `compute(...)` must re-check runtime cancellation through the runtime's central cancellation state before it: + +- resumes a request continuation +- feeds the returned event back into the loop +- mutates more state indirectly through another call to `update` + +The important detail is that this re-check should use the runtime's central cancellation mechanism rather than ad-hoc boolean tests. + +That preserves the latched shutdown reason and keeps the thrown error consistent with the rest of the runtime's boundary behavior. + +--- + +## Shutdown and failure authority + +The runtime needs one authoritative place for shutdown and latched failure state. + +In the current implementation, much of that responsibility lives in `TaskManager`. A future runtime `Context` could own that state more directly while still preserving the same semantic contract. + +Conceptually, this authority owns: + +- managed task tracking +- overlap policy for logically identified tasks +- task waiter sets +- latched cancellation and shutdown state +- the distinction between normal managed cancellation and fatal runtime failure + +This is why `checkCancellation()` is such a central primitive. It turns internal runtime state into a public execution rule: if the runtime is no longer accepting work, the current path must stop. + +Keeping this authority centralized prevents the runtime from drifting into multiple slightly different notions of cancellation. + +--- + +## Continuation ownership + +Request continuations intentionally have asymmetric ownership. + +During regular synchronous computation, the continuation is owned by the current `compute(...)` frame. + +If the chain reaches a managed task, ownership transfers to `TaskManager`, which resumes the waiting caller when that task completes, fails, or is cancelled. + +If `compute(...)` throws, the continuation is not consumed by `compute(...)`. The caller that entered `compute(...)` remains responsible for resuming or failing it. + +This rule is especially important for interrupted async actions: + +- the async action resumes +- `compute(...)` re-checks cancellation +- `compute(...)` throws because the runtime was invalidated mid-flight +- the outer caller maps or forwards that failure and resumes the continuation exactly once + +That ownership discipline avoids double-resume bugs and keeps failure propagation localized. + +--- + +## Managed tasks and overlap semantics + +Managed tasks solve a different problem from async actions. + +They represent asynchronous work that should be tracked by logical identifier and governed by overlap policy. + +The runtime currently supports at least two overlap behaviors: + +- `switchToLatest`: cancel the current physical task instance, keep the waiter set, and move those waiters to the replacement task +- `subscribe`: keep the running task and attach the new waiter to the existing logical work + +This model gives the runtime a principled answer to overlapping requests without requiring feature code to store task handles manually. + +It also means the runtime can express request/response style behavior without forcing every transducer to implement its own queue or subscription bookkeeping in domain state. + +--- + +## Why `control(...)` is a promising extension point + +The `compute(...)` / `control(...)` split does more than enable interruption. + +It also creates a real control plane. + +Because `control(...)` is runtime-facing and storage-safe, it can host future runtime features without polluting the domain event model. + +Plausible extensions include: + +- diagnostics and runtime introspection +- fault injection in tests +- tracing and instrumentation +- runtime lifecycle commands +- reporting current gate or task-manager state + +For example, a diagnostic control event could print or export current runtime context, including task-manager state, without pretending that diagnostics are part of the feature's domain event vocabulary. + +This is one of the design's strongest architectural consequences: operational concerns get a dedicated channel with dedicated rules. + +--- + +## Why this is a Swift-native design + +This runtime model leans heavily on Swift's specific features: + +- actor isolation provides serialization boundaries +- structured concurrency expresses back pressure naturally as suspension +- continuations bridge event-driven logic to async/await callers +- `Sendable` strengthens boundary discipline +- first-class closures make dependency injection through `Env` lightweight + +That combination makes it realistic to implement what is effectively an FSM effect actor without introducing a large framework or an elaborate supervisory architecture. + +Other languages may need different primitives, especially if they lack actor isolation or typed continuation-style suspension. In Swift, this design maps naturally onto the language rather than fighting it. + +--- + +## Testable consequences + +The following behaviors should remain pinned down by tests: + +1. `compute(...)` is not re-entered while another regular event chain is active. +2. A control event may cancel the runtime while `compute(...)` is suspended in an async action. +3. After that interruption, the suspended `compute(...)` frame does not continue processing returned events. +4. A request interrupted during an async action completes with the correct runtime failure semantics. +5. Managed task overlap policies continue to preserve waiter ownership correctly. +6. Control events never mutate transducer storage. + +These tests are not implementation details. They are executable statements of the design. + +--- + +## Summary + +The runtime is intentionally built around four ideas: + +1. regular event reduction is serialized through gated `compute(...)` +2. runtime control is separated into ungated `control(...)` +3. caller suspension provides the primary back pressure mechanism +4. `TaskManager` centralizes shutdown and task-lifecycle semantics + +This gives `EffectView` a runtime that stays small in code size while still supporting async actions, request/response bridging, runtime-managed tasks, immediate interruption, and future runtime control features. + +The design is intentional, not accidental. \ No newline at end of file diff --git a/Documentation/SwiftUIFirst.md b/Documentation/SwiftUIFirst.md index 6056620..4e4c78d 100644 --- a/Documentation/SwiftUIFirst.md +++ b/Documentation/SwiftUIFirst.md @@ -34,10 +34,13 @@ A ViewModel in the iOS world is typically a class that holds `@Published` proper With EffectView, state is a Swift value type (`struct` or `enum`) owned by the caller via `Binding`. There is no class, no `@Published`, no `objectWillChange`, and no `deinit` to worry about. State is just data. -Logic lives in a pure function: +Logic lives in the transducer's transition function: ```swift -func update(state: inout State, event: Event) -> Effect? +static func update( + _ state: inout State, + event: Event +) -> Self.Effect? ``` This function is not an object. It has no stored properties, no lifecycle, and no hidden shared state. It is easier to read, easier to test, and easier to trace than a ViewModel — and it does more, because it explicitly models every side effect as a return value rather than as a fire-and-forget call inside an async method. @@ -56,7 +59,7 @@ Dependencies are declared as structs of closures in the feature module. Concrete ### No Combine -Combine was the bridge between async work and `@Published` properties on the main thread. Structured concurrency obsoletes most of that. EffectView's `Input` type handles dispatch from any isolation — `send` for synchronous `@MainActor` calls, `enqueue` for fire-and-forget from background tasks, and `perform` when you need to await acknowledgement. +Combine was the bridge between async work and `@Published` properties on the main thread. Structured concurrency obsoletes most of that. EffectView's `Input` type handles dispatch from any isolation — `send` for synchronous `@MainActor` calls, `post` for fire-and-forget from background tasks, and `request` when you need to await acknowledgement. --- @@ -64,7 +67,7 @@ Combine was the bridge between async work and `@Published` properties on the mai | Concern | Solution | Article | |---|---|---| -| Async task management | Named, cancellable tasks via `Effect.task` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) | +| Async task management | Identified, cancellable tasks via `Effect.run` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) | | Correctness and logic | FSM update function, impossible states unrepresentable | [Correct by Construction](CorrectByConstruction.md) | | Dependency injection | Struct of closures + SwiftUI environment + `EnvReader` | [Using Env for Dependency Injection](UsingEnvForDependencyInjection.md) | @@ -78,10 +81,11 @@ Combine was the bridge between async work and `@Published` properties on the mai │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ EnvReader(\.myEnv) { env in │ │ -│ │ EffectView(state: $state, │ │ +│ │ EffectView(of: Logic.self, │ │ +│ │ state: $state, │ │ │ │ initialEnv: env, │ │ -│ │ update: Logic.update) { state, send │ │ -│ │ MyContent(state: state, send: send) │ │ +│ │ ) { state, input in │ │ +│ │ MyContent(state: state, send: input) │ │ │ │ } │ │ │ │ } │ │ │ └─────────────────────────────────────────────────────┘ │ diff --git a/Documentation/TamingAsyncTasksInSwiftUIViews.md b/Documentation/TamingAsyncTasksInSwiftUIViews.md index aa84a39..f5170dc 100644 --- a/Documentation/TamingAsyncTasksInSwiftUIViews.md +++ b/Documentation/TamingAsyncTasksInSwiftUIViews.md @@ -13,11 +13,12 @@ This article walks through those walls one by one, and shows how `EffectView` ad But event-driven systems have a structural limitation: **you cannot directly `await` a logical operation**. You can only fire an event and move on: ```swift -input.send(.fetch) -// ... that's it. No return value. No error. No completion signal. +try input.post(.fetch) +// ... that's it. The event is scheduled, but there is still +// no completion signal for the logical operation. ``` -The result of the fetch arrives later, indirectly, as a state change triggered by a `.loaded` or `.loadFailed` event. To know when the operation is complete, you have to observe state — which is cumbersome every time you need to bridge between the event-driven world and a caller that expects `async`/`await` semantics. +The result of the fetch arrives later, indirectly, as a state change triggered by a `.loaded` or `.loadFailed` event. If dispatch itself fails, you learn that immediately. But to know when the operation is complete, you still have to observe state — which is cumbersome every time you need to bridge between the event-driven world and a caller that expects `async`/`await` semantics. This friction shows up acutely with SwiftUI's `.refreshable` modifier. It needs something to `await` — a suspension that holds the system refresh spinner until the work is genuinely done. An event-driven system has no natural answer for this. Sending `.refresh` returns immediately; the spinner would dismiss before the data has arrived. @@ -28,19 +29,21 @@ The same problem appears anywhere a caller needs to know when an event's consequ `Input` provides three methods that give you precise control over how much of the event chain you wait for: ```swift -input(.loaded(items)) // fire-and-forget: schedules event, returns immediately -await input.send(.loaded(items)) // wait until update() has run -await input.perform(.loaded(items)) // wait until update() *and all resulting effects* have settled +try input.post(.loaded(items)) // fire-and-forget: schedules event, returns immediately +try await input.send(.loaded(items)) +// wait until update() has run +try await input.request(.loaded(items)) +// wait until update() *and all resulting effects* settle ``` -`perform` is the full bridge. It threads a continuation through the entire effect chain — if `.loaded` returns another effect, and that effect eventually completes, `perform` resumes only after all of it has settled. The call site reads like ordinary async/await code while the FSM continues to own all state mutations: +`request` is the full bridge. It threads a continuation through the entire effect chain — if `.loaded` returns another effect, and that effect eventually completes, `request` resumes only after all of it has settled. The call site reads like ordinary async/await code while the FSM continues to own all state mutations: ```swift // In a .refreshable block — the spinner holds until the full load cycle is complete: -await input.perform(.refresh) +try? await input.request(.refresh) // In a test — assert state only after the operation has fully settled: -await input.perform(.load) +try await input.request(.load) #expect(state.items.count == 20) ``` @@ -91,9 +94,9 @@ Two `.task` modifiers on the same view run independently. If one depends on the Stepping back, the requirements that fall out of real apps are: 1. Task lifetime is tied to the **view's logical identity**, not to individual renders. -2. Tasks can be **cancelled by name** from any event — a button tap, a timeout, a competing task starting. -3. A **dynamic number** of named tasks can run concurrently. -4. Starting a new task with a name that's already running **automatically cancels the previous one** — no manual bookkeeping. +2. Tasks can be **cancelled by identifier** from any event — a button tap, a timeout, a competing task starting. +3. A **dynamic number** of identified tasks can run concurrently. +4. Starting new work with an identifier that's already running **automatically cancels the previous one** — no manual bookkeeping. 5. Results feed back into the view through a **single, ordered mutation point** — no scattered `@State` writes racing each other. 6. The `refreshable` spinner stays visible until the **full effect chain completes** — not just until the first `await`. @@ -106,41 +109,48 @@ Stepping back, the requirements that fall out of real apps are: `EffectView` separates concerns cleanly: - **`update`** — a pure function `(inout State, Event) -> Effect?`. All state mutations happen here. No async, no throwing, just a switch. -- **`Effect`** — what the view asks the runtime to do next: start a named task, cancel a named task, fire a synchronous action chain, or a sequence of the above. +- **`Effect`** — what the view asks the runtime to do next: start tracked work, cancel tracked work, fire a synchronous action chain, or a sequence of the above. - **`Input`** — how async work sends events back into the update loop. -Tasks are identified by name strings at runtime, owned by the `EffectView` for its identity lifetime, and cancelled automatically when the view disappears. +Tasks are identified by logical identifiers at runtime, owned by the `EffectView` for its identity lifetime, and cancelled automatically when the view disappears. ### `refreshable` that actually waits -`perform(_:)` suspends the caller until the full effect chain — including any task that runs and sends events back — has completed. This makes it a natural fit for `refreshable`: +`request(_:)` suspends the caller until the full effect chain — including any task that runs and sends events back — has completed. This makes it a natural fit for `refreshable`: ```swift List(state.items, id: \.self) { Text($0) } .refreshable { - await input.perform(.refresh) // spinner stays until .refresh effect finishes + try? await input.request(.refresh) + // spinner stays until .refresh settles } ``` -`.refresh` is a plain event. The work is a named task returned from `update`: +`.refresh` is a plain event. The work is a task returned from `update`: ```swift case .refresh: - return .task(name: "refresh") { input, env in - let items = try await env.fetch() - await input.perform(.loaded(items)) + return .task(id: "refresh") { input, env in + do { + let items = try await env.fetch() + return try await input.request(.loaded(items)) + } catch { + return try await input.request( + .loadFailed(error.localizedDescription) + ) + } } ``` The three dispatch strategies on `Input` differ in what they wait for: -- **`input(.loaded(items))`** — calls `enqueue`, which schedules the event on the `@MainActor` and returns immediately. The task closure exits before `update` processes `.loaded(items)`, so the outer `perform(.refresh)` continuation resumes before the state is updated. The spinner disappears too early. +- **`try input.post(.loaded(items))`** — schedules the event on the `@MainActor` and returns immediately. The task closure exits before `update` processes `.loaded(items)`, so the outer `request(.refresh)` continuation resumes before the state is updated. The spinner disappears too early. -- **`await input.send(.loaded(items))`** — hops to the `@MainActor` and runs `update(.loaded(items))` synchronously before returning. The state is updated before the closure exits. However, `send` passes a `nil` continuation, so if `.loaded` itself returns an effect — another task, an action chain — that effect's completion is not awaited. The closure exits as soon as `update` returns, regardless of what the effect does next. +- **`try await input.send(.loaded(items))`** — hops to the `@MainActor` and runs `update(.loaded(items))` synchronously before returning. The state is updated before the closure exits. However, `send` passes a `nil` continuation, so if `.loaded` itself returns an effect — another task, an action chain — that effect's completion is not awaited. The closure exits as soon as `update` returns, regardless of what the effect does next. -- **`await input.perform(.loaded(items))`** — threads the outer continuation through the entire effect chain triggered by `.loaded(items)`. The closure only exits once `update` has run *and* any effect it returned has fully settled. This is the correct choice here: it handles the simple case identically to `send`, and correctly extends the wait if `.loaded` ever grows to return an effect of its own. +- **`try await input.request(.loaded(items))`** — threads the outer continuation through the entire effect chain triggered by `.loaded(items)`. The closure only exits once `update` has run *and* any effect it returned has fully settled. This is the correct choice here: it handles the simple case identically to `send`, and correctly extends the wait if `.loaded` ever grows to return an effect of its own. -The rule of thumb: use `perform` when the result needs to be complete before the caller resumes; use `enqueue` for fire-and-forget signals where ordering doesn't matter. +The rule of thumb: use `request` when the result needs to be complete before the caller resumes; use `post` for fire-and-forget signals where ordering doesn't matter. ### Cancel on user intent @@ -155,13 +165,19 @@ That's it. No stored `Task` handle, no flag, no `id:` dance. ### Dynamic number of tasks -Because task names are runtime strings, you can start as many tasks as the data dictates and cancel any individual one: +Because task identifiers can be created at runtime, you can start as many tasks as the data dictates and cancel any individual one: ```swift case .startDownload(let id): - return .task(name: "download-\(id)") { input, env in - let data = try await env.download(id) - input.enqueue(.downloaded(id, data)) + return .run(id: "download-\(id)") { input, env in + do { + let data = try await env.download(id) + try? input.post(.downloaded(id, data)) + } catch { + try? input.post( + .downloadFailed(id, error.localizedDescription) + ) + } } case .cancelDownload(let id): @@ -172,16 +188,16 @@ No ViewModel, no array of handles, no manual lifecycle. ### Automatic cancel-and-restart (debounce / live search) -Starting a task whose name is already running cancels the previous run first. Debounce is just a `Task.sleep` inside the operation — the restart behaviour is free: +Starting a task whose identifier is already running cancels the previous run first. Debounce is just a `Task.sleep` inside the operation — the restart behaviour is free: ```swift case .queryChanged(let q): state.query = q - return .task(name: "search") { input, env in + return .run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) - input.enqueue(.resultsLoaded(results)) + try? input.post(.resultsLoaded(results)) } ``` diff --git a/Documentation/UsingEnvForDependencyInjection.md b/Documentation/UsingEnvForDependencyInjection.md index 73c3060..ac9a749 100644 --- a/Documentation/UsingEnvForDependencyInjection.md +++ b/Documentation/UsingEnvForDependencyInjection.md @@ -64,10 +64,13 @@ struct MovieSearchEnv: Sendable { Either layout works. The struct-of-structs pattern scales better when several features share a dependency group. -The `update` function type annotation now becomes: +The transducer's `update` requirement now becomes: ```swift -(inout MovieSearchState, MovieSearchEvent) -> Effect? +static func update( + _ state: inout MovieSearchState, + event: MovieSearchEvent +) -> Self.Effect? ``` And inside a task, `env` is simply the injected value: @@ -77,13 +80,13 @@ case (_, .searchTapped(let query)): state = .loading(query: query) return .sequence([ .cancel("search"), - .task(name: "search") { input, env in + .run(id: "search") { input, env in env.trackQuery(query) do { let movies = try await env.search(query) - input.enqueue(.resultsReceived(movies)) + try? input.post(.resultsReceived(movies)) } catch { - input.enqueue(.requestFailed(error.localizedDescription)) + try? input.post(.requestFailed(error.localizedDescription)) } } ]) @@ -158,20 +161,20 @@ struct MovieSearchView: View { var body: some View { EnvReader(\.movieSearchEnv) { env in EffectView( + of: MovieSearchLogic.self, state: $state, - initialEnv: env, - update: MovieSearchLogic.update - ) { state, send in - MovieSearchContent(state: state, send: send) + initialEnv: env + ) { state, input in + MovieSearchContent(state: state, send: input) } } } } ``` -`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and EffectView takes ownership from there — forwarding it to every `.task` and `.action` for the lifetime of the view. +`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and EffectView takes ownership from there — forwarding it to every `.run`, `.request`, and `.action` for the lifetime of the view. -Note that `update` is referenced as a static function (`MovieSearchLogic.update`) rather than a closure literal. This is not required, but it keeps the view body free of logic and makes the update function easily findable and independently testable. +Note that the transducer type (`MovieSearchLogic.self`) is passed directly rather than constructing an inline closure. This keeps the view body free of logic and makes the transition function easily findable and independently testable. --- @@ -191,7 +194,7 @@ struct MovieSearchTransitionTests { @Test func searchTappedTransitionsToLoading() { var state = MovieSearchState.idle - let effect = MovieSearchLogic.update(state: &state, event: .searchTapped(query: "inception")) + let effect = MovieSearchLogic.update(&state, event: .searchTapped(query: "inception")) #expect(state == .loading(query: "inception")) #expect(effect != nil) @@ -201,7 +204,7 @@ struct MovieSearchTransitionTests { var state = MovieSearchState.loading(query: "inception") let movies = try await testEnv.search("inception") - _ = MovieSearchLogic.update(state: &state, event: .resultsReceived(movies)) + _ = MovieSearchLogic.update(&state, event: .resultsReceived(movies)) #expect(state == .loaded(query: "inception", results: movies)) } diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj index c769731..9513626 100644 --- a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj +++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj @@ -414,7 +414,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -447,7 +446,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..aceb888 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b971c143a10f44d540b259eca77415d2cc7af5727022d1fbfa7ed14ee8bb1f80", + "pins" : [ + { + "identity" : "swift-mutex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-mutex.git", + "state" : { + "revision" : "1770152df756b54c28ef1787df1e957d93cc62d5", + "version" : "0.0.6" + } + } + ], + "version" : 3 +} diff --git a/Examples/EffectViewExample/EffectViewExample/App.swift b/Examples/EffectViewExample/EffectViewExample/App.swift index 3747db9..ec38b9a 100644 --- a/Examples/EffectViewExample/EffectViewExample/App.swift +++ b/Examples/EffectViewExample/EffectViewExample/App.swift @@ -6,12 +6,12 @@ struct EffectViewExampleApp: App { var body: some Scene { WindowGroup { TabView { - Counter.ContentView() + Counter.Views.ContentView() .tabItem { Label("Counter", systemImage: "plus.forwardslash.minus") } - Movies.ContentView() + Movies.Views.ContentView() .tabItem { Label("Movies", systemImage: "film") } diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift index c957f9d..8be6ec6 100644 --- a/Examples/EffectViewExample/EffectViewExample/Counter.swift +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -2,17 +2,64 @@ import SwiftUI import Foundation import EffectView -public enum Counter {} +enum Counter { + enum Views {} + enum Transducer {} +} // MARK: - Environment - extension EnvironmentValues { - @Entry var counterViewEnv: Counter.CounterView.Env = .init() + @Entry var counterViewEnv: Counter.Transducer.Env = .init() } -// MARK: - Views +// MARK: - Transducer +extension Counter.Transducer: EffectView::Transducer { + + struct State { + var counter = 0 + init() { + self.counter = 0 + } + } + + enum Event { + case start + case tick + case stop + } + + struct Env: Identifiable { + let id: UUID = .init() + init() {} + } + + static func update( + _ state: inout State, + event: Event + ) -> Self.Effect? { + switch event { + case .start: + state.counter = 0 + return run(id: "Counter") { input, env in + while true { + do { + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec + print("tick") + input(.tick) + } catch {} // most likeley, the counter task has been cancelled; ignore it. + } + } + case .tick: + state.counter += 1; return nil + case .stop: + return cancel("Counter") + } + } + +} -extension Counter { +// MARK: - Views +extension Counter.Views { struct ContentView: View { var body: some View { @@ -23,61 +70,18 @@ extension Counter { } struct CounterView: View { - - struct ViewState { - var counter = 0 - init() { - self.counter = 0 - } - } - - enum Event { - case start - case tick - case stop - } - - struct Env: Identifiable { - let id: UUID = .init() - init() {} - } + typealias Transducer = Counter.Transducer + typealias Env = Transducer.Env + typealias ViewState = Transducer.State @State private var state: ViewState = .init() - let env: Env - @MainActor - private static func update( - state: inout ViewState, - event: Event - ) -> Effect? { - switch event { - case .start: - state.counter = 0 - return .run(name: "Counter") { input, env in - while true { - do { - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec - print("tick") - input(.tick) - } catch { - // most likeley, the counter task has been cancelled; ignore it. - } - } - } - case .tick: - state.counter += 1 - return nil - case .stop: - return .cancel("Counter") - } - } - var body: some View { EffectView( + of: Transducer.self, state: $state, initialEnv: env, - update: Self.update ) { state, send in VStack { Text("\(state.counter)") @@ -91,8 +95,6 @@ extension Counter { } } -#if false #Preview { - Counter.ContentView() + Counter.Views.ContentView() } -#endif diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift index 32c8766..b2b6957 100644 --- a/Examples/EffectViewExample/EffectViewExample/Movies.swift +++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift @@ -2,14 +2,17 @@ import SwiftUI import EffectView import Foundation -public enum Movies {} +enum Movies { + enum Views {} + enum Transducer {} +} // MARK: - Model extension Movies { - public struct Movie: Equatable, Identifiable { - public let id: UUID - public let title: String + struct Movie: Equatable, Identifiable { + let id: UUID + let title: String } } @@ -17,21 +20,18 @@ extension Movies { extension Movies { - public struct MovieFetch: Sendable { - public var fetch: @Sendable () async throws -> [Movie] + struct MovieFetch: Sendable { + var fetch: @Sendable () async throws -> [Movie] - public func callAsFunction() async throws -> [Movie] { + func callAsFunction() async throws -> [Movie] { try await fetch() } } - public struct Env: Sendable { - public var movieFetch: Movies.MovieFetch - } } extension EnvironmentValues { - @Entry public var movieListViewEnv: Movies.Env = .init( + @Entry var movieListViewEnv: Movies.Transducer.Env = .init( movieFetch: .init(fetch: { try await Task.sleep(nanoseconds: 2_000_000_000) return [ @@ -45,72 +45,12 @@ extension EnvironmentValues { } -// MARK: - Views - -extension Movies { - - public struct ContentView: View { - public var body: some View { - EnvReader(\.movieListViewEnv) { env in - Movies.MovieListView(env: env) - } - } - } - - struct MovieListView: View { - let env: Env - - @State private var state = ViewState() - - var body: some View { - EffectView(state: $state, initialEvent: .load, initialEnv: env, update: Self.update) { state, input in - ZStack { - switch state.content { - case .empty: - if #available(iOS 17.0, *) { - ContentUnavailableView("No Movies", systemImage: "film") - } else { - VStack(spacing: 12) { - Image(systemName: "film") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No Movies") - .font(.headline) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - case .content(let movies): - List(movies, rowContent: MovieRow.init) - .refreshable { - await input.request(.refresh) - } - } - - if state.isLoading { - ProgressView() - } - } - .alert( - "Error", - isPresented: .constant(state.error != nil), - presenting: state.error - ) { _ in - Button("OK") { input.send(.dismiss) } - } message: { error in - Text(error.localizedDescription) - } - } - } - } - -} - -extension Movies.MovieListView { +// MARK: - Transducer +extension Movies.Transducer: Transducer { typealias Movie = Movies.Movie - struct ViewState { + struct State { var mode: Mode var content: Content<[Movie]> @@ -129,7 +69,7 @@ extension Movies.MovieListView { var error: Error? { if case .failed(let error) = mode { return error } return nil - } + } var isLoading: Bool { switch mode { @@ -144,7 +84,7 @@ extension Movies.MovieListView { } } - enum Event { + public enum Event { case load case refresh case loaded([Movie]) @@ -152,9 +92,12 @@ extension Movies.MovieListView { case cancel case dismiss } - - @MainActor - static func update(state: inout ViewState, event: Event) -> Effect? { + + struct Env: Sendable { + public var movieFetch: Movies.MovieFetch + } + + static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: // Guard against refresh: can only race with programmatic load triggers @@ -162,12 +105,12 @@ extension Movies.MovieListView { guard !state.isRefreshing else { return nil } guard !state.isLoading else { return nil } state.mode = .loading - return .loadMovies() + return loadMovies() case .refresh: // Always supersedes a pending load; named task cancels any prior refresh. state.mode = .refreshing - return .sequence([.cancel("load"), .refreshMovies()]) + return sequence([cancel("load"), refreshMovies()]) case .loaded(let movies): state.mode = .idle @@ -180,31 +123,17 @@ extension Movies.MovieListView { case .cancel: state.mode = .idle - return .cancel("load") + return cancel("load") case .dismiss: state.mode = .idle return nil } } - -} - -extension Movies.MovieListView { - struct MovieRow: View { - let movie: Movie - - var body: some View { - Text(movie.title) - } - } -} - -// MARK: - Custom Effects - -extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { - static func loadMovies() -> Self { - .run(name: "load") { input, env in + + + static func loadMovies() -> Effect { + run(id: "load") { input, env in do { let movies = try await env.movieFetch() input(.loaded(movies)) @@ -214,9 +143,9 @@ extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { } } - static func refreshMovies() -> Self { + static func refreshMovies() -> Effect { // Note: a refresh action - .run(name: "refresh") { input, env in + run(id: "refresh") { input, env in do { let movies = try await env.movieFetch() input(.loaded(movies)) @@ -227,11 +156,92 @@ extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { } } +// MARK: - Views +extension Movies.Views { + + struct ContentView: View { + var body: some View { + EnvReader(\.movieListViewEnv) { env in + MovieListView(env: env) + } + } + } + + struct MovieListView: View { + typealias Transducer = Movies.Transducer + typealias Env = Transducer.Env + typealias ViewState = Transducer.State + + let env: Env + + @State private var state = ViewState() + + var body: some View { + EffectView( + of: Transducer.self, + state: $state, + initialEvent: .load, + initialEnv: env + ) { state, input in + ZStack { + switch state.content { + case .empty: + if #available(iOS 17.0, *) { + ContentUnavailableView("No Movies", systemImage: "film") + } else { + VStack(spacing: 12) { + Image(systemName: "film") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No Movies") + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + case .content(let movies): + List(movies, rowContent: MovieRow.init) + .refreshable { + try? await input.request(.refresh) + } + } + + if state.isLoading { + ProgressView() + } + } + .alert( + "Error", + isPresented: .constant(state.error != nil), + presenting: state.error + ) { _ in + Button("OK") { input(.dismiss) } + } message: { error in + Text(error.localizedDescription) + } + } + } + } + +} + +extension Movies.Views.MovieListView { + + typealias Movie = Movies.Movie + + struct MovieRow: View { + let movie: Movie + + var body: some View { + Text(movie.title) + } + } +} + // MARK: - Previews #Preview { EnvReader(\.movieListViewEnv) { env in - Movies.MovieListView(env: env) + Movies.Views.MovieListView(env: env) } } - diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift index bf53699..b17e1e8 100644 --- a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift +++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift @@ -3,8 +3,9 @@ import Foundation import EffectView @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -public enum RemoteCounter { - public enum Views {} +enum RemoteCounter { + enum Views {} + enum Transducer {} } // MARK: - Remote Store @@ -18,6 +19,10 @@ extension RemoteCounter { /// does not publish a stream. @Observable @MainActor final class CounterStore: Sendable { + + static let shared: CounterStore = .init() + + private init() {} enum Event { case increment, decrement, reset } @@ -27,97 +32,113 @@ extension RemoteCounter { switch event { case .increment: count += 1 case .decrement: count -= 1 - case .reset: count = 0 + case .reset: count = 0 } } } } -// MARK: - Views +// MARK: - Environment +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension EnvironmentValues { + @Entry var remoteCounterEnv: RemoteCounter.Transducer.Env = .init(store: .shared) +} +// MARK: - Transducer @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -extension RemoteCounter.Views { +extension RemoteCounter.Transducer: Transducer { + + struct State { + var count: Int = 0 + var lastDelta: Int = 0 + } - struct ContentView: View { + enum Event { + case start + case storeChanged(newCount: Int) + case incrementTapped + case decrementTapped + case resetTapped + } - /// The store lives here — single instance for this subtree. - @State private var store = RemoteCounter.CounterStore() + struct Env: Identifiable { + public let id: UUID = .init() + let store: RemoteCounter.CounterStore + } + + static func update( + _ state: inout State, + event: Event + ) -> Effect? { + switch event { + + case .start: + return observe( + \.store, keyPath: \.count, + id: "observe-store-count" + ) { input, value in + print("observation-handler store.count: ", value) + try? await input.request(.storeChanged(newCount: value)) + } - var body: some View { - CounterView(env: .init(store: store)) + case .storeChanged(let newCount): + // The only path that writes the mirrored value. + print("received event: \(event), state: \(state)") + + state.lastDelta = newCount - state.count + state.count = newCount + return nil + + case .incrementTapped: + print("incrementTapped") + return run { input, env in + await env.store.send(.increment) + } + + case .decrementTapped: + print("decrementTapped") + return run { _, env in + await env.store.send(.decrement) + } + + case .resetTapped: + print("resetTapped") + return run { _, env in + await env.store.send(.reset) + } } } +} - struct CounterView: View { +// MARK: - Views +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension RemoteCounter.Views { + + typealias Transducer = RemoteCounter.Transducer + typealias ViewState = Transducer.State + typealias Env = Transducer.Env - struct ViewState { - var count: Int = 0 - var lastDelta: Int = 0 - } + struct ContentView: View { - enum Event { - case start - case storeChanged(newCount: Int) - case incrementTapped - case decrementTapped - case resetTapped + var body: some View { + EnvReader(\.remoteCounterEnv) { env in + CounterView(env: env) + } } + } - struct Env: Identifiable { - let id: UUID = .init() - let store: RemoteCounter.CounterStore - } + struct CounterView: View { @State private var state = ViewState() let env: Env - @MainActor - static func update( - _ state: inout ViewState, - event: Event - ) -> Effect? { - switch event { - - case .start: - return .observe( - \.store, keyPath: \.count, - name: "observe-store-count" - ) { @MainActor input, value in - print("observe-store-count: ", value) - await input.request(.storeChanged(newCount: value)) - } - - case .storeChanged(let newCount): - // The only path that writes the mirrored value. - print("received event: \(event)") - - state.lastDelta = newCount - state.count - state.count = newCount - return nil - - case .incrementTapped: - return .run { _, env in - await env.store.send(.increment) - } - - case .decrementTapped: - return .run { _, env in - await env.store.send(.decrement) - } - - case .resetTapped: - return .run { _, env in - await env.store.send(.reset) - } - } - } var body: some View { EffectView( + of: Transducer.self, state: $state, initialEvent: .start, - initialEnv: env, - update: Self.update(_:event:) + initialEnv: env ) { state, send in VStack(spacing: 20) { Text("\(state.count)") @@ -137,7 +158,7 @@ extension RemoteCounter.Views { private func deltaLabel(_ delta: Int) -> String { switch delta { - case 0: return " " + case 0: return "0" case 1...: return "+\(delta)" default: return "\(delta)" } @@ -146,7 +167,6 @@ extension RemoteCounter.Views { } // MARK: - Previews - #Preview { if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { RemoteCounter.Views.ContentView() diff --git a/Package.swift b/Package.swift index 4a1b359..f6f3e20 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,10 @@ let package = Package( // Targets can depend on other targets in this package and products from dependencies. .target( name: "EffectView", + dependencies: [ + .product(name: "Mutex", package: "swift-mutex"), + ], swiftSettings: [ - .enableUpcomingFeature("InferSendableFromCaptures"), ] ), .testTarget( @@ -38,7 +40,6 @@ let package = Package( .product(name: "Mutex", package: "swift-mutex"), ], swiftSettings: [ - .enableUpcomingFeature("InferSendableFromCaptures"), ] ), ], diff --git a/README.md b/README.md index fc93fc7..d3434c0 100644 --- a/README.md +++ b/README.md @@ -3,429 +3,151 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectView%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/couchdeveloper/EffectView) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectView%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/couchdeveloper/EffectView) -A concrete SwiftUI pattern for state, events, and async effects — without an `@Observable` class, without scattered ad-hoc methods, favouring an event-driven, MVI-style design. +EffectView is for SwiftUI developers who are tired of ViewModels that keep absorbing async methods, loading flags, `Task` handles, cancellation logic, and UI glue. -- Single mutation point via `update`. -- Explicit effects (`task`, `action`, `cancel`). -- Optional dependency environment captured for the view lifetime. +It gives your view one event-driven place where state changes are decided. -## The problem with the conventional approach +## The problem -In a typical SwiftUI view backed by an `@Observable` ViewModel, state is mutated from many places — `onAppear`, button handlers, async task completions, timers. As the view grows: +Most ViewModels start small and end up like this: -- Two tasks can race to update the same property. -- An `isLoading` flag gets set to `false` before a second request finishes. -- A cancelled task still calls back and overwrites fresh state. -- Testing requires constructing the whole ViewModel and observing side effects. +- button handlers mutate state +- async callbacks mutate state +- refresh and search race each other +- old work finishes late and overwrites fresh UI +- tests have to drive a whole reference type instead of one transition function -None of these are bugs you wrote on purpose. They're structural: there's no single, authoritative place that says "given this state and this event, here is the new state". +The issue is usually not the architecture name. The issue is that state changes are spread across too many places. -EffectView gives you that place. +## The solution -## What you get +With EffectView, you move a feature's logic into a small stand-alone enum that declares `State`, `Event`, and one `update` function. -- **One transition function owns all state changes.** `update` takes the current state and an event, and returns new state plus an optional effect — no `async`, no network calls inside it, just logic. The same event on the same state always produces the same outcome. Nothing else in the view can mutate state. -- **Finite state machine rigour, without the ceremony.** All transitions live in one exhaustive `switch` over your `Event` enum. The compiler tells you when you've missed a case. No hidden paths, no forgotten edge cases. -- **Async work is explicit and named.** Nothing runs unless `update` returned an `Effect`. Tasks are tracked by name, automatically cancelled when the view disappears, and replaced if re-issued. -- **Test the entire view logic without a simulator.** Because `update` is a transition function with no async or network calls inside it, you can drive state, events, and async effects from a plain XCTest — no SwiftUI, no `@MainActor`, no mocking framework. +`update` is a plain synchronous function: it receives the current state and an event, changes state, and decides what should happen next. It does not call services, start tasks, or cause side effects itself. - -## How it maps to patterns you know - -If you've used **VIPER**, think of `update` as the Presenter and Interactor collapsed into a single transition function. Events are inputs from the View; effects are the work the Interactor would kick off. The key difference: nothing executes inside `update` — it only *describes* what should happen. The library executes it. - -If you use **MVVM with `@Observable`**, `ViewState` replaces your ViewModel's published properties, and `Event` replaces your ViewModel's public methods. The mental shift is that instead of calling `viewModel.loadMovies()` imperatively, you send an event and `update` decides what effect to run. - -## Installation - -Add the package to your Swift Package Manager dependencies: +If more work is needed, `update` returns an effect: just a function, possibly async, that the runtime executes one step later and where those side effects happen. That split keeps the logic easy to read and easy to test. ```swift -// Package.swift -.package(url: "https://github.com/couchdeveloper/EffectView.git", from: "0.1.0") -``` - -Then add `EffectView` to your target dependencies. - -## Usage +import EffectView +import SwiftUI -1. **Define `State` and `Event`.** `State` is a plain value type holding everything the view needs to render. `Event` is an enum of all user actions and system notifications that can change state. - -2. **Define the transition function `update`.** A `static` function that takes the current state and an event, mutates state in place, and optionally returns an `Effect` to run or cancel. No async, no throwing — just a switch. - -3. **Render and send.** The `EffectView` content closure receives the current state and a `send` function. Render state, and call `send` for user actions. - -4. **Design service functions.** Long-running or async work lives in `.task` effects. These receive an `input` parameter which can be used to dispatch events back to the update loop as work progresses or completes. The example below where a `tick` event is sent back via input: `input(.tick)`: - -```swift -struct CounterView: View { - struct ViewState { var counter = 0 } - enum Event { case start, tick, stop } - - @State private var state = ViewState() - - private static func update( - state: inout ViewState, - event: Event - ) -> Effect? { - switch event { - case .start: - state.counter = 0 - return .task(name: "Counter") { input, env in - while true { - do { - try await Task.sleep(for: .seconds(1)) - input(.tick) - } catch { - // ignore cancellation - } - } - } - case .tick: - state.counter += 1 - return nil - case .stop: - return .cancel("Counter") - } - } - - var body: some View { - EffectView(state: $state, update: Self.update) { state, send in - VStack { - Text("\(state.counter)") - Button("Start") { send(.start) } - Button("Stop") { send(.stop) } - } - } +enum SearchFeature: Transducer { + struct State { + var query = "" + var isLoading = false + var results: [String] = [] + var errorMessage: String? } -} -``` - -## What would take 20 lines in a ViewModel takes 5 here - -Live search with automatic cancel-on-type — a task named `"search"` is automatically cancelled and restarted every time the query changes: - -```swift -// update: -case .queryChanged(let q): - state.query = q - return .task(name: "search") { input, env in - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - let results = await env.search(q) - await input.perform(.resultsLoaded(results)) - } -``` - -No manual `Task` handles. No `debounce` publisher chain. No flag to reset. -## Behavior notes - -- `update` is captured once when the view appears. -- The lifetime of any running task is controlled by the `EffectView`. All tasks are automatically cancelled when the view's identity ceases to exist. A task can also be cancelled earlier by returning `.cancel(name)` from `update`. - -## Effect - -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. 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 - -For readability, effects can be declared as static factory methods on `Effect` constrained to the view's `Event` and `Env` types. This keeps `update` free of construction details and makes effects reusable across multiple cases. - -```swift -extension Effect where Event == MyView.Event, Env == MyView.Env { - static func loadItems() -> Self { - .task(name: "load") { input, env in - do { - let items = try await env.fetch() - await input.perform(.loaded(items)) - } catch { - await input.perform(.loadFailed(error)) - } - } + enum Event { + case queryChanged(String) + case searchResponse([String]) + case searchFailed(String) } -} -``` - -`update` can then return `.loadItems()` instead of spelling out the full task inline. - - -## Env and View identity - -If you pass `initialEnv`, it is captured once when the view appears. This is intentional — swapping dependencies mid-flight can cause subtle bugs where a running task started with one implementation finishes against another. - -The environment value is passed as an argument to the effect's operation and action closure and can carry dependencies or configuration values, see custom effect example above. -To apply new dependencies, recreate the view identity with `.id(...)`. - -## Dependency injection - -Declare dependencies as a struct in the view layer. This keeps the interface close to the consumer and makes swapping implementations (e.g. live vs. mock) straightforward. - -```swift -struct CounterView: View { - struct Env: Identifiable { - let id: UUID - // Declare the API the view layer needs. - var fetchInitialCount: () async -> Int - - static let live = Env(id: UUID(), fetchInitialCount: { await CounterService.shared.count() }) - static let mock = Env(id: UUID(), fetchInitialCount: { 42 }) + struct Env: Sendable { + var search: @Sendable (String) async throws -> [String] } - enum Event { case appeared, loaded(Int) } - struct ViewState { var count: Int? } - - @State private var state = ViewState() - let env: Env - - private static func update(state: inout ViewState, event: Event) -> Effect? { + static func update(_ state: inout State, event: Event) -> Effect? { switch event { - case .appeared: - return .task { send, env in - let count = await env.fetchInitialCount() - await send.perform(.loaded(count)) + case .queryChanged(let query): + state.query = query + state.isLoading = true + state.errorMessage = nil + + return .run(id: "search") { input, env in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + + do { + let results = try await env.search(query) + try? input.post(.searchResponse(results)) + } catch { + try? input.post(.searchFailed(error.localizedDescription)) + } } - case .loaded(let count): - state.count = count + + case .searchResponse(let results): + state.results = results + state.isLoading = false return nil - } - } - var body: some View { - EffectView(state: $state, initialEnv: env, update: Self.update) { state, send in - Text(state.count.map { "\($0)" } ?? "Loading…") - .task { send(.appeared) } + case .searchFailed(let message): + state.results = [] + state.errorMessage = message + state.isLoading = false + return nil } - .id(env.id) } } ``` -At the call site, pass the environment that fits the context — no changes to the view or update logic required: +`update` is the only place that decides how the feature changes. -```swift -CounterView(env: .live) // production -CounterView(env: .mock) // previews, tests -``` +When `update` returns `nil`, processing stops there. When `update` returns `.run(id: "search")`, the runtime starts that async job, tracks it by identifier, and routes follow-up events back through `update`. -## Dependency injection via SwiftUI Environment +That means no `Task?` stored in a ViewModel, no ad-hoc mutation from random callbacks, and no guessing where the last state change came from. -For dependencies that need to be available deep in the view hierarchy, you can deliver them through the SwiftUI environment using `EnvReader`. Wrap each injectable operation in a lightweight `Action` struct so the environment key stays typed and the default implementation is co-located with the declaration. +## Use it from SwiftUI ```swift -// 1. Declare the action -struct CounterAction: Sendable { - var fetchCount: @Sendable () async -> Int = { await CounterService.shared.count() } -} +struct SearchView: View { + @State private var state = SearchFeature.State() -extension EnvironmentValues { - @Entry var counterAction = CounterAction() -} + let env: SearchFeature.Env -// 2. Compose the view's Env from the environment at the call site -struct CounterContainerView: View { var body: some View { - EnvReader(\.counterAction) { action in - CounterView( - env: .init(id: UUID(), - fetchInitialCount: action.fetchCount) - ) - } - } -} -``` - -In tests or previews, override just the actions you need: - -```swift -CounterContainerView() - .environment(\.counterAction, CounterAction(fetchCount: { 42 })) -``` - -This keeps each injectable operation minimal and composable. The view layer owns the interface; the environment owns the wiring. - -## Recipes - -Short, focused snippets for common patterns. Each one highlights a specific feature in isolation. - ---- - -### Pull-to-refresh - -`perform(_:)` suspends until the full effect chain completes, which makes it a natural fit for SwiftUI's `refreshable` modifier. - -```swift -List(state.movies, rowContent: MovieRow.init) - .refreshable { - await input.perform(.refresh) // spinner shown until .refresh effect completes - } -``` - -`.refresh` is just a regular event. The actual async work is a custom effect returned from `update`: + EffectView( + of: SearchFeature.self, + state: $state, + initialEnv: env + ) { state, input in + VStack { + TextField( + "Search", + text: Binding( + get: { state.query }, + set: { try? input.post(.queryChanged($0)) } + ) + ) + + if state.isLoading { + ProgressView() + } -```swift -extension Effect where Event == MyView.Event, Env == MyView.Env { - static func refreshMovies() -> Self { - .task(name: "refresh") { input, env in - do { - let movies = try await env.movieFetch() - await input.perform(.loaded(movies)) - } catch { - await input.perform(.loadFailed(error)) + List(state.results, id: \.self, rowContent: Text.init) } + .padding() } } } ``` ---- - -### Cancel-and-restart (debounce / live search) - -Name the task. A new event with the same task name cancels the previous run automatically before starting a fresh one. - -```swift -// update: -case .queryChanged(let q): - state.query = q - return .task(name: "search") { input, env in - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - let results = await env.search(q) - await input.perform(.resultsLoaded(results)) - } -``` - ---- - -### Synchronous action chain (setup sequence) - -`.action` returns the next event to process immediately in the same run loop. Use this to break multi-step setup into deterministic, individually-testable events. - -```swift -// update: -case .appeared: - return .action { _ in .loadConfig } // processed synchronously before any external event -case .loadConfig: - state.config = Config.default - 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) - -`input` is callable directly. Use it anywhere a `() -> Void` closure is expected. - -```swift -Button("Retry", action: input(.retry)) // callAsFunction — enqueues on MainActor -Toggle("Sync", isOn: $state.syncEnabled) - .onChange(of: state.syncEnabled) { input(.syncToggled($0)) } -``` - ---- - -### Cancel before starting - -`.sequence` runs effects left-to-right. Useful when you need to cancel a stale task before issuing a new one in the same update step. - -```swift -// update: -case .refresh: - return .sequence([.cancel("load"), .task(name: "load") { … }]) -``` - ---- - -### Await a sub-operation from another task - -From inside a `.task`, use `perform(_:)` to drive the FSM and wait for the state change to settle before continuing. - -```swift -return .task { input, env in - await input.perform(.prepareUpload) // waits for prepareUpload's full effect chain - let result = await env.upload(…) - await input.perform(.uploadFinished(result)) -} -``` - ---- - -### Observe an external `@Observable` store +The view renders state and posts events. The feature logic stays in `update`. -Use `.observe` to mirror a property from an external `@Observable` object into `ViewState`. The handler is called with the initial value immediately and again on every subsequent change. The named task is cancelled automatically when the view disappears. +## Why this is useful -Two overloads are available depending on where the observable object comes from. +- state changes stay local and explicit +- async work is started from one place +- repeated work can be replaced by identifier +- tests can drive `update` with plain values -**When the store lives in `Env`** — use the env key path overload. The object is resolved inside the task, so nothing is captured at `update` time: - -```swift -struct Env: Identifiable { - let id: UUID = .init() - let store: CounterStore // an @Observable @MainActor object -} - -// update: -case .start: - return .observe(\.store, keyPath: \.count, name: "observe-count") { input, count in - await input.perform(.countChanged(count)) // perform waits before the next cycle - } - -case .countChanged(let count): - state.count = count // the only path that writes the mirrored value - return nil - -case .incrementTapped: - return .task { _, env in - await env.store.send(.increment) // write via the store's own event API - } -``` - -**When the store comes from elsewhere** — for example imported into the view by a previous event — use the direct overload and pass the object itself: +## Installation ```swift -// update: -case .storeReceived(let store): - state.store = store - return .observe(store, keyPath: \.count, name: "observe-count") { input, count in - await input.perform(.countChanged(count)) - } +.package(url: "https://github.com/couchdeveloper/EffectView.git", from: "0.1.0") ``` -The effect holds the object weakly; the observation loop exits automatically if the object is deallocated before the task is cancelled. - -> **Requires** iOS 17 / macOS 14 or later (the `@Observable` macro minimum deployment). - ---- - -## Contributing - -Contributions are welcome. Please follow the [Git workflow](Documentation/GitWorkflow.md) used in this project. - -This project uses [Conventional Commits](https://www.conventionalcommits.org/) for all commit messages. After cloning, run the following once to activate the commit message template: - -```bash -git config commit.template .github/commit-template -``` +Add `EffectView` to your target dependencies. -The template is included in the repository at `.github/commit-template`. +## Learn more ---- +- [Recipes](Documentation/Recipes.md) +- [SwiftUI first](Documentation/SwiftUIFirst.md) +- [Taming async tasks in SwiftUI views](Documentation/TamingAsyncTasksInSwiftUIViews.md) +- [Bridging event-driven and imperative code](Documentation/BridgingEventDrivenAndImperative.md) ## License -Apache License, Version 2.0 +Apache License, Version 2.0 \ No newline at end of file diff --git a/Sources/EffectView/Effect.swift b/Sources/EffectView/Effect.swift deleted file mode 100644 index a400966..0000000 --- a/Sources/EffectView/Effect.swift +++ /dev/null @@ -1,151 +0,0 @@ - -/// A value describing a side effect to run after a state transition. -/// -/// `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 -/// // Fire-and-forget task: -/// return .run(name: "ticker") { input, env in -/// while true { -/// try await env.clock.sleep(for: .seconds(1)) -/// input(.tick) -/// } -/// } -/// -/// // 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 -/// } -/// ``` -/// -/// ### 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 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 -> Output? - ) - - /// 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? - ) - - /// Feeds `event` back into `update` immediately, in the current synchronous turn. - case event(Event) - - /// Cancels the running task with the given name, if any. - case cancel(String) - - /// 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()]) - /// ``` - /// - /// - 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/EffectObservable/EffectObservable.Input.swift b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift new file mode 100644 index 0000000..763b2df --- /dev/null +++ b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift @@ -0,0 +1,146 @@ +/// A `Sendable` handle for dispatching events into the effect engine. +/// +/// `EffectObservableInput` provides three dispatch strategies with different semantics: +/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`. +/// - ``post(_:)`` — fire-and-forget; safe from any isolation. +/// - ``request(_:)`` — suspends the caller, returning `Output?`. +/// +/// ### Isolation and lifetime safety +/// +/// 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. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +extension EffectObservable { + + public struct Input: TransducerInput, Sendable { + + init(_ actor: EffectObservable) { + self.actor = actor + } + + private weak let actor: EffectObservable? + + /// 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 ``post(_:)`` 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. ``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 observable runtime. + /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has + /// already been released, plus any error that ``EffectObservable/send(_:)`` + /// would throw for the same event. + @MainActor + public func send(_ event: sending Event) async throws { + guard let actor else { + throw RuntimeUnavailable.actorDeallocated + } + try await actor.send(event) + } + + /// 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. + /// + /// - Parameter event: The event to enqueue into the observable runtime. + /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has + /// already been released, or the current latched runtime failure if the runtime + /// is no longer accepting work. + @inline(__always) + public func post(_ event: sending Event) throws { + guard let actor else { + throw RuntimeUnavailable.actorDeallocated + } + try actor.checkRuntimeAvailability() + Task { @MainActor in + 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 `@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, + /// `request` continues to wait until the effect chain settles. + /// - Note: If the observable runtime has already shut down, `request` + /// throws ``RuntimeUnavailable`` immediately instead of entering the runtime. + /// - Parameter event: The event to send into the observable runtime. + /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has + /// already been released, plus any error that ``EffectObservable/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 . + @discardableResult + public func request( + _ event: Event + ) async throws -> Output? where Output: Sendable, Event: Sendable { + guard let actor else { + throw RuntimeUnavailable.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) + } + } +} diff --git a/Sources/EffectView/EffectObservable/EffectObservable.swift b/Sources/EffectView/EffectObservable/EffectObservable.swift new file mode 100644 index 0000000..d435ffe --- /dev/null +++ b/Sources/EffectView/EffectObservable/EffectObservable.swift @@ -0,0 +1,282 @@ +import Observation + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +@MainActor +@Observable +/// Observable host for a transducer-driven runtime. +/// +/// `EffectObservable` stores the current transducer `State`, exposes it +/// through Swift Observation, and provides an ``Input`` handle for sending +/// events back into the runtime from views, tasks, and callbacks. +/// +/// Construct the observable once for the host view's lifetime. The runtime +/// captures `Env` at initialization, updates ``state`` only through the +/// transducer, and keeps event processing serialized on the `@MainActor`. +public final class EffectObservable< + T: Transducer, +> where + T.Output: Sendable, + T.Env: Sendable, + T.Effect == TransducerEffect, + T.Event: Sendable +{ + public typealias State = T.State + public typealias Event = T.Event + public typealias Env = T.Env + public typealias Output = T.Output + public typealias Effect = T.Effect + + typealias Storage = UnownedReferenceKeyPathStorage + typealias Send = EffectView::Send + + + /// Current transducer state published through Swift Observation. + internal(set) public var state: State + + @ObservationIgnored + private var send: Send? + @ObservationIgnored + nonisolated(unsafe) private var runtimeUnavailable: RuntimeUnavailable? + @ObservationIgnored + private var initialEvent: Event? + @ObservationIgnored + private var storage: Storage! + @ObservationIgnored + private var _input: Input! + + + /// Creates an observable runtime with a captured dependency environment. + /// + /// Use `initialEvent` to kick off startup work after construction. The + /// event is scheduled asynchronously; the initializer does not wait for + /// that work to finish before returning. + /// + /// - Parameters: + /// - of: The transducer type. + /// - initialState: The initial value for ``state``. + /// - initialEvent: An optional event sent when the view first appears. + /// - env: Dependencies captured for the runtime lifetime. + public init( + of: T.Type = T.self, + initialState: State, + initialEvent: Event? = nil, + env: Env + ) { + self.state = initialState + self.initialEvent = initialEvent + self.storage = .init(host: self, keyPath: \.state) + let send = T.makeSend( + with: Input.self, + storage: storage, + env: env + ) + self._input = Input(self) + self.send = send + if let event = initialEvent { + Task { + do { + try await send(event, input: _input, continuation: nil) + } catch { + print("could not process initial event: \(error)") + // TODO: consider sending a control event + } + } + } + } + + isolated deinit { + cancel() + } + + /// Sends the given event into the transducer. + /// + /// The event will be processed by the transducer's update function, which may + /// return an effect which may itself return an event. This event is synchronously + /// processed by the update function. The chain of events is processed until no + /// further events are returned. + /// + /// If the update function returns a task effect, this task will be started. This + /// also terminates the event processing chain and `send` returns. + /// + /// When `send` returns the transducer has fully processed the event, that is it has + /// updated its state accordingly, and started all effect tasks returned by the + /// update function during processing of the event. However, any async operations + /// in those tasks may continue to run. + /// + /// - Caution: `send` preserves ordered inline reduction. If the current event chain + /// reaches a long-running awaited step, the caller remains suspended until that + /// step yields control back to the runtime. + /// + /// - seealso: **Effect Operations and Actions** + /// + /// - Note: `send` may suspend only when it executes suspending effect actions. + /// + /// - Parameter event: The event that is sent into the system. + /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been + /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a + /// critical failure, or `CancellationError` if accepted work is later cancelled. + public func send(_ event: Event) async throws { + try checkRuntimeAvailability() + guard let send = send else { + throw RuntimeUnavailable.actorCancelled + } + do { + try await send(event, input: _input, continuation: nil) + } catch { + let boundaryError = runtimeBoundaryError(for: error) + if let runtimeUnavailable = boundaryError as? RuntimeUnavailable { + self.runtimeUnavailable = runtimeUnavailable + } + throw boundaryError + } + } + + /// Sends `event` and suspends until operations of the resulting effect chain complete. + /// + /// The event will be processed by the transducer's update function, which may + /// return an effect which may itself return an event. This event is synchronously + /// processed by the update function. The chain of events is processed until no + /// further events are returned. + /// + /// If the update function returns a task, this task will be executed and `request` will + /// suspend until the task's operation completes, returning the task's output. + /// This also terminates the event processing chain. + /// + /// When `request` returns the transducer has fully processed the event, that is it has + /// updated its state accordingly, and started and awaited the effect task returned + /// by the update function during processing of the event. In the mean time, the + /// transducer can receive and process other events, but the caller is suspended until + /// the effect task triggered by this event has completed. + /// + /// - seealso: **Effect Operations and Actions** + /// + /// ## Effect Operations and Actions + /// + /// If the chain reaches a named task, overlapping waiters for the same task + /// identifier are coalesced according to that task's ``TaskExecutionOption``. + /// The caller is waiting for the current active task for that identifier, not + /// necessarily for the first physical task instance that was started. + /// + /// 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 + /// ``` + /// + /// - Caution: Cancelling the caller does not immediately tear down an accepted request. + /// The runtime resumes the continuation only after the in-flight chain reaches a + /// terminal outcome or the runtime reports cancellation. + /// + /// - Parameter event: The event that is sent into the system. + /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been + /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a + /// critical failure before the request can enter or while it is running, or + /// `CancellationError` if accepted work is later cancelled. + /// - Returns: the `Output?` value produced by the terminal `.task` closure. + public func request(_ event: Event) async throws -> Output? { + // TODO: consider to to add a Task cancellation handler which sends a corresponding control event to the transducer. + // The transducer's action on this is currently "implementation defined". It *could* have no effect on the task operation, or it *could* cancel it. + try checkRuntimeAvailability() + guard let send = self.send, let input = _input else { + throw RuntimeUnavailable.actorCancelled + } + return try await withCheckedThrowingContinuation { (continuation: Continuation) in + Task { + do { + try await send.send(MainActor.shared, event, input, continuation) + } catch { + let boundaryError = runtimeBoundaryError(for: error) + if let runtimeUnavailable = boundaryError as? RuntimeUnavailable { + self.runtimeUnavailable = runtimeUnavailable + } + continuation.resume(throwing: boundaryError) + } + } + } + } + + /// Dispatch handle for sending events into this runtime. + public var input: Input { + _input + } + + /// Immediately cancels the observable runtime. + /// + /// After cancellation, newly created ``input`` handles will no longer + /// deliver events, and pending ``Input/request(_:)`` calls resolve with + /// `nil` if they reach the cancelled runtime after teardown. + /// + /// This is host-level disposal, not graceful transducer shutdown. Model a + /// gentle teardown as an event handled by the transducer itself, then call + /// `cancel()` when the host is ready to discard the runtime. + public func cancel() { + cancelRuntime(with: RuntimeUnavailable.actorCancelled) + } + + /// Cancels the observable runtime with a caller-provided system error. + /// + /// Use this when the host needs pending work to observe a specific + /// failure at the runtime boundary rather than a generic cancellation. + /// + /// - Parameter error: The system-level failure to latch and broadcast. + public func cancel(with error: any Swift.Error) { + cancelRuntime(with: error) + } + + // MARK - + + nonisolated + func checkRuntimeAvailability() throws { + if let runtimeUnavailable { + throw runtimeUnavailable + } + } + + private func cancelRuntime(with systemError: any Swift.Error) { + guard runtimeUnavailable == nil else { + return + } + + runtimeUnavailable = .actorCancelled + + guard let send else { + return + } + + Task { @MainActor [send] in + try? send.control(ControlEvent.systemError(systemError)) + } + } + +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +extension EffectObservable where Env == Void { + + /// Creates an observable runtime with no external dependencies. + /// + /// - Parameters: + /// - of: The transducer type. + /// - initialState: The initial value for ``state``. + /// - initialEvent: An optional event sent when the view first appears. + convenience public init( + of transducer: T.Type = T.self, + initialState: State, + initialEvent: Event? = nil + ) { + self.init( + of: transducer, + initialState: initialState, + initialEvent: initialEvent, + env: () + ) + } +} + diff --git a/Sources/EffectView/EffectObservable/Transducer.observe.swift b/Sources/EffectView/EffectObservable/Transducer.observe.swift new file mode 100644 index 0000000..98a92d8 --- /dev/null +++ b/Sources/EffectView/EffectObservable/Transducer.observe.swift @@ -0,0 +1,420 @@ +import Foundation +import Mutex +import Observation + +// MARK: - Transducer.observe + +extension Transducer where Effect == TransducerEffect { + + /// 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/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.request(.countChanged(count)) + /// } + /// ``` + /// + /// The named task (`"observe"` by default) is cancelled automatically when the view + /// disappears, or immediately when `update` returns `.cancel(name)`. + /// + /// - Parameters: + /// - envKeyPath: Key path from `Env` to the `@Observable` object. The object is held + /// weakly inside the task; the loop exits when it is deallocated. + /// - keyPath: The property on the object to observe. + /// - id: 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.request(…)` to wait for the + /// view to settle before the next observation cycle. + // TODO: possibly use a async *throwing* function for the operation and forward the error + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) + public static func observe( + _ envKeyPath: KeyPath, + keyPath: KeyPath, + id: TaskIdentifier? = "observe", + priority: TaskPriority? = nil, + handler: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value) async -> Void + ) -> Effect + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) + return task(id: id, priority: priority) { input, env in + do { + let weakObject = WeakObject(object: env[keyPath: envKeyPathBox.keyPath]) + await handler(input, try observedValue(weakObject, keyPath: box)) + while true { + try await _waitForObservationChange(weakObject, keyPath: box) + await handler(input, try observedValue(weakObject, keyPath: box)) + } + } catch is CancellationError { + // Transducer logic has cancelled. Do not rethrow. + // Expected termination path for explicit .cancel(name) or view teardown. + } catch is ObservationTerminationError { + // TODO: consider rethrow + // IFF we would rethrow the error, the update function is responsible + // to catch and handle it, or otherwise it becomes a critical + // system error. + + // For now we end the observation quietly. + } catch { + // TODO: consider rethrow + // IFF we would rethrow the error, the update function is responsible + // to catch and handle it, or otherwise it becomes a critical + // system error. + assertionFailure("Unexpected observation failure: \(error)") + } + return nil + } + } + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) + /// Observes an environment-resolved key path and runs the callback on the host actor. + /// + /// Semantics match the `handler`-based overload, but `isolatedHandler` receives the + /// current host actor isolation explicitly. + /// + /// - Parameters: + /// - systemActor: The host actor isolation forwarded into `isolatedHandler`. + /// - envKeyPath: Key path from `Env` to the `@Observable` object. + /// - keyPath: The property on the object to observe. + /// - id: Optional name for the underlying task. Defaults to `"observe"`. + /// - priority: Optional `TaskPriority` for the underlying task. + /// - isolatedHandler: Called with `input`, the current value, and the host actor. + public static func observe( + systemActor: isolated (any Actor)? = #isolation, + _ envKeyPath: KeyPath, + keyPath: KeyPath, + id: TaskIdentifier? = "observe", + priority: TaskPriority? = nil, + isolatedHandler: @escaping (any TransducerInput, Value, isolated any Actor) async -> Void + ) -> Effect + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) + return task(id: id, priority: priority) { input, env, isolation in + do { + precondition( + systemActor != nil && systemActor === isolation, + "observe(isolatedOperation:) requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." + ) + let weakObject = WeakObject(object: env[keyPath: envKeyPathBox.keyPath]) + await isolatedHandler(input, try observedValue(weakObject, keyPath: box), isolation) + while true { + try await _waitForObservationChange(weakObject, keyPath: box) + await isolatedHandler(input, try observedValue(weakObject, keyPath: box), isolation) + } + } + catch is CancellationError { + // Expected termination path for explicit .cancel(name) or view teardown. + } catch is ObservationTerminationError { + // Observed object deallocated; end the observation quietly. + } catch { + assertionFailure("Unexpected observation failure: \(error)") + } + return nil + } + } + + /// 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/post(_:)``, ``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.request(.countChanged(count)) + /// } + /// ``` + /// + /// The named task (`"observe"` by default) is cancelled automatically when the view + /// disappears, or immediately when `update` returns `.cancel(name)`. + /// + /// - Parameters: + /// - object: The `@Observable` object to watch. Held weakly inside the task so the + /// effect does not extend the object's lifetime. The loop exits when `object` is + /// deallocated. + /// - keyPath: The property to observe. + /// - id: Optional name for the underlying task. Defaults to `"observe"`. + /// - priority: Optional `TaskPriority` for the underlying task. + /// - operation: Called with `input` and the current value on the initial read and on + /// 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, visionOS 1.0, *) + public static func observe( + _ object: Object, + keyPath: KeyPath, + id: TaskIdentifier? = "observe", + priority: TaskPriority? = nil, + operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value) async -> Void + ) -> Effect + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + let weakObject = WeakObject(object: object) + return task(id: id, priority: priority) { input, env in + do { + await operation(input, try observedValue(weakObject, keyPath: box)) + while true { + try await _waitForObservationChange(weakObject, keyPath: box) + await operation(input, try observedValue(weakObject, keyPath: box)) + } + } catch is CancellationError { + // Expected termination path for explicit .cancel(name) or view teardown. + } catch is ObservationTerminationError { + // Observed object deallocated; end the observation quietly. + } catch { + assertionFailure("Unexpected observation failure: \(error)") + } + return nil + } + } + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) + /// Observes a directly provided key path and runs the callback on the host actor. + /// + /// Semantics match the `operation`-based overload, but `isolatedOperation` receives the + /// current host actor isolation explicitly. + /// + /// - Parameters: + /// - systemActor: The host actor isolation forwarded into `isolatedOperation`. + /// - object: The `@Observable` object to watch. + /// - keyPath: The property to observe. + /// - id: Optional name for the underlying task. Defaults to `"observe"`. + /// - priority: Optional `TaskPriority` for the underlying task. + /// - isolatedOperation: Called with `input`, the current value, and the host actor. + public static func observe( + systemActor: isolated (any Actor)? = #isolation, + _ object: Object, + keyPath: KeyPath, + id: TaskIdentifier? = "observe", + priority: TaskPriority? = nil, + isolatedOperation: @escaping (any TransducerInput, Value, isolated any Actor) async -> Void + ) -> Effect + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + let weakObject = WeakObject(object: object) + return task(id: id, priority: priority) { input, env, isolation in + do { + precondition( + systemActor != nil && systemActor === isolation, + "observe(isolatedOperation:) requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." + ) + await isolatedOperation(input, try observedValue(weakObject, keyPath: box), isolation) + while true { + try await _waitForObservationChange(weakObject, keyPath: box) + await isolatedOperation(input, try observedValue(weakObject, keyPath: box), isolation) + } + } catch is CancellationError { + // Expected termination path for explicit .cancel(name) or view teardown. + } catch is ObservationTerminationError { + // Observed object deallocated; end the observation quietly. + } catch { + assertionFailure("Unexpected observation failure: \(error)") + } + return nil + } + } + +} + +// MARK: - Internal helpers + +/// A minimal `@unchecked Sendable` box for `KeyPath`. +/// +/// `KeyPath` is a value type with no mutable state — it is intrinsically safe to share +/// across concurrency domains. This wrapper makes that explicit so key path values can be +/// captured in `@Sendable` closures without requiring `SE-0418` (`InferSendableFromCaptures`) +/// at every call site. +private struct SendableKeyPath: @unchecked Sendable { + let keyPath: KeyPath +} + +private final class ObservationContinuationBox: @unchecked Sendable { + private enum State { + case pending(CheckedContinuation?) + case resolved(Result) + } + + private let state = Mutex(State.pending(nil)) + + init() {} + + func install(_ continuation: CheckedContinuation) { + let resultToResume: Result? = state.withLock { state in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + fatalError("Observation continuation installed more than once") + case .resolved(let result): + return result + } + } + + if let resultToResume { + resumeContinuation(continuation, with: resultToResume) + } + } + + func resume() { + resolve(with: .success(())) + } + + func resume(throwing error: Error) { + resolve(with: .failure(error)) + } + + private func resolve(with result: Result) { + let continuationToResume = state.withLock { state in + switch state { + case .pending(let continuation): + state = .resolved(result) + return continuation + case .resolved: + return nil + } + } + + if let continuationToResume { + resumeContinuation(continuationToResume, with: result) + } + } +} + +private struct WeakObject: @unchecked Sendable where Object: Sendable { + weak var object: Object? +} + +private enum ObservationTerminationError: Error { + case deallocated +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +@inline(__always) +private func observedValue( + _ weakObject: WeakObject, + keyPath box: SendableKeyPath +) throws -> Value where Object: Observable & AnyObject & Sendable, Value: Sendable { + guard let object = weakObject.object else { + throw ObservationTerminationError.deallocated + } + return object[keyPath: box.keyPath] +} + +@inline(__always) +private func resumeContinuation( + _ continuation: CheckedContinuation, + with result: Result +) { + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } +} + + +/// Observes a key path on an `@Observable` object, calling `handler` +/// with each new value until the task is cancelled or `object` is +/// deallocated. +/// +/// - Parameters: +/// - systemActor: The actor isolation used to deliver observed values to `handler`. +/// - object: The observable object to watch. +/// - keyPath: The property to observe on `object`. +/// - handler: The async callback invoked with each observed value. +/// - Throws: If observation is cancelled or the object becomes unavailable before +/// the next value can be delivered. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +public func observeKeyPath( + systemActor: isolated any Actor = #isolation, + _ object: Object, + keyPath: KeyPath, + handler: @escaping (isolated any Actor, Value) async -> Void +) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { + let box = SendableKeyPath(keyPath: keyPath) + let weakObject = WeakObject(object: object) + + try await observeWeakKeyPath(systemActor: systemActor, weakObject, keyPath: box, isolatedHandler: handler) +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +private func observeWeakKeyPath( + systemActor: isolated any Actor = #isolation, + _ weakObject: WeakObject, + keyPath box: SendableKeyPath, + isolatedHandler: @escaping (isolated any Actor, Value) async -> Void +) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { + let initialValue = try observedValue(weakObject, keyPath: box) + + // Seed the initial value — withObservationTracking only fires on *changes*. + await isolatedHandler(systemActor, initialValue) + + while true { + try await _waitForObservationChange(weakObject, keyPath: box) + await isolatedHandler(systemActor, try observedValue(weakObject, keyPath: box)) + } +} + +/// Legacy one-shot waiter for `observeKeyPath` on macOS < 26. +/// +/// `onChange` fires *before* the new value is committed and on an arbitrary thread, so a +/// child `Task` hops back onto `systemActor` before resuming the suspended observer. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +private func _waitForObservationChange( + _ weakObject: WeakObject, + keyPath box: SendableKeyPath, +) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { + let continuationBox = ObservationContinuationBox() + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + continuationBox.install(continuation) + + func installObservation() { + guard weakObject.object != nil else { + continuationBox.resume(throwing: ObservationTerminationError.deallocated) + return + } + withObservationTracking( + { _ = weakObject.object?[keyPath: box.keyPath] }, + onChange: { + Task { + continuationBox.resume() + } + } + ) + } + + installObservation() + } + } onCancel: { + continuationBox.resume(throwing: CancellationError()) + } +} + diff --git a/Sources/EffectView/EffectView.swift b/Sources/EffectView/EffectView.swift deleted file mode 100644 index 54c24eb..0000000 --- a/Sources/EffectView/EffectView.swift +++ /dev/null @@ -1,344 +0,0 @@ -import SwiftUI - -/// A SwiftUI view that manages structured side effects via an Elm-style update loop. -/// -/// `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 -/// -/// ```swift -/// enum Event { case increment, reset } -/// struct MyState { var count = 0 } -/// -/// @State private var state = MyState() -/// -/// 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 -/// Button("\(state.count)") { send(.increment) } -/// } -/// ``` -/// -/// ### Using `Env` for dependencies -/// -/// 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 api: any APIClient } -/// -/// EffectView( -/// state: $state, -/// initialEnv: Env(api: liveAPI), -/// update: { state, event in -/// 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 -/// } -/// } -/// ) { state, send in -/// Button("Load") { send(.load) } -/// } -/// ``` -/// -/// ### Env changes -/// -/// 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`. -/// -/// ### 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 - - 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 - - - /// Creates an effect-managed view with a captured dependency environment. - /// - /// `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`). - /// - /// ```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. - /// - 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 - ) { - self.state = state - self.initialEvent = initialEvent - self.env = initialEnv - self.update = update - self.content = content - } - - public var body: some View { - HStack { - if let input { - content(self.state.wrappedValue, input) - } else { - // transparent placeholder; holds layout until effectManager is ready - Color.clear - .frame(maxWidth: 1, maxHeight: 1) - } - } - .task { - guard self.input == nil else { - return - } - - let effectManager = EffectManager() - let stateBinding = self.state - let env = self.env - let update = self.update - let send = { @MainActor @Sendable (event: Event, input: Input, continuation: CheckedContinuation?) in - Self.compute( - event: event, - continuation: continuation, - state: stateBinding, - effectManager: effectManager, - input: input, - env: env, - update: update - ) - } - self.input = Input(send: send) - if let event = initialEvent { - input?.send(event) - } - } - } - - private static func compute( - event: Event, - continuation: CheckedContinuation?, - state: Binding, - effectManager: EffectManager, - input: Input, - env: Env, - update: (inout State, Event) -> Effect? - ) { - var nextEvent: Event? = event - var cont = continuation - while let event = nextEvent { - nextEvent = nil - if let effect = update(&state.wrappedValue, event) { - (nextEvent, cont) = executeEffect( - effect, - continuation: cont, - effectManager: effectManager, - input: input, - env: env - ) - } else { - cont?.resume(returning: nil) - cont = nil - } - } - assert(cont == nil) - } - - private static func executeEffect( - _ effect: Effect, - continuation: CheckedContinuation?, - effectManager: EffectManager, - input: Input, - env: Env - ) -> (Event?, CheckedContinuation?) { - switch effect { - case .task(name: let name, priority: let priority, operation: let operation): - effectManager.add( - name: name, - priority: priority, - operation: { - let output = await operation(input, env) - continuation?.resume(returning: output) - } - ) - return (nil, nil) - - case .event(let event): - return (event, continuation) - - case .action(action: let action): - let event = action(env) - if event == nil { - continuation?.resume(returning: nil) - return (nil, nil) - } - return (event, continuation) - - case .cancel(let name): - effectManager.cancel(name: name) - continuation?.resume(returning: nil) - return (nil, nil) - - case .sequence(let effects): - guard let last = effects.last else { - continuation?.resume(returning: nil) - return (nil, nil) - } - for effect in effects.dropLast() { - _ = executeEffect(effect, continuation: nil, effectManager: effectManager, input: input, env: env) - } - return executeEffect(last, continuation: continuation, effectManager: effectManager, input: input, env: env) - } - } -} - -extension EffectView where Env == Void { - - /// 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(...)`. - /// - /// ```swift - /// EffectView( - /// state: $state, - /// update: Self.update - /// ) { state, send in - /// Button("Start") { send(.start) } - /// } - /// ``` - /// - /// - 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 - ) { - self.state = state - self.initialEvent = initialEvent - self.env = () - self.update = update - self.content = content - } -} - -// MARK: - Implementation - -@MainActor -fileprivate final class EffectManager { - private var tasks: Set = [] - - init() { - print("EffectManager: init") - } - - isolated deinit { - print("EffectManager: deinit") - tasks.forEach { $0.task.cancel() } - } - - @discardableResult - func cancel(name: String) -> Bool { - if let taskId = tasks.first(where: { $0.name == name }) { - taskId.task.cancel() - return true - } else { - return false - } - } - - func add( - name: String? = nil, - priority: TaskPriority? = nil, - operation: sending @escaping @isolated(any) () async -> Void - ) { - if let taskName = name { - cancel(name: taskName) - } - let id = Self.makeTaskID(name: name, priority: priority, operation: operation) - tasks.insert(id) - Task { [weak self] in - defer { - self?.complete(id: id) - } - await id.task.value - } - } - - private struct TaskID: Hashable, Equatable { - let name: String? - let task: Task - } - - private func complete(id: TaskID) { - guard let _ = tasks.remove(id) else { - fatalError("could not find task with id \(id)") - } - } - - private static func makeTaskID( - name: String? = nil, - priority: TaskPriority? = nil, - operation: sending @escaping @isolated(any) () async -> Void - ) -> TaskID { - TaskID( - name: name, - task: Task(priority: priority, operation: operation) - ) - } - -} diff --git a/Sources/EffectView/EffectView/EffectView.swift b/Sources/EffectView/EffectView/EffectView.swift new file mode 100644 index 0000000..6b05fbd --- /dev/null +++ b/Sources/EffectView/EffectView/EffectView.swift @@ -0,0 +1,203 @@ +import SwiftUI + +/// A SwiftUI view that manages structured side effects via an Elm-style update loop. +/// +/// `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. +/// The supplied transducer type is the single mutation authority: it receives events, +/// mutates state, and optionally returns an ``Effect`` to run or cancel. +/// +/// ### Basic usage +/// +/// ```swift +/// typealias Counter = CounterFeature.Transducer +/// +/// @State private var state = Counter.State() +/// +/// EffectView( +/// of: Counter.self, +/// state: $state, +/// ) { state, input in +/// Button("\(state.count)") { +/// try? input.post(.increment) +/// } +/// } +/// ``` +/// +/// ### Using `Env` for dependencies +/// +/// Pass dependencies (clocks, API clients, etc.) via `Env`. The value is captured +/// once when the view appears and forwarded to every effect. +/// +/// ```swift +/// typealias Feature = MoviesFeature.Transducer +/// @State private var state = Feature.State() +/// struct Env { let api: any APIClient } +/// +/// EffectView( +/// of: Feature.self, +/// state: $state, +/// initialEnv: Env(api: liveAPI), +/// ) { state, input in +/// Button("Load") { +/// try? input.post(.load) +/// } +/// } +/// ``` +/// +/// ### Env changes +/// +/// 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`. +/// +/// ### 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< + T: Transducer, + Content: View +>: View where T.Output: Sendable, T.Env: Sendable, T.Effect == TransducerEffect, T.Event: Sendable { + + public typealias State = T.State + public typealias Event = T.Event + public typealias Env = T.Env + public typealias Output = T.Output + public typealias Effect = T.Effect + + public typealias Input = EffectViewInput + + @SwiftUI.State private var send: Send? + + private var state: Binding + private var initialEvent: Event? + private let env: Env + private let content: (State, Input) -> Content + + + /// Creates an effect-managed view with a captured dependency environment. + /// + /// The transducer type, `initialEvent`, and `initialEnv` 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 when that identity model + /// makes sense for your feature. + /// + /// ```swift + /// EffectView( + /// of: Feature.self, + /// state: $state, + /// initialEnv: env, + /// ) { state, input in + /// Button("Start") { + /// try? input.post(.start) + /// } + /// } + /// .id(env.id) + /// ``` + /// + /// - Parameters: + /// - of: The transducer type. + /// - 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. + /// - content: Builds the view from current state and an ``Input`` handle. + public init( + of: T.Type = T.self, + state: Binding, + initialEvent: Event? = nil, + initialEnv: Env, + @ViewBuilder content: @escaping (State, Input) -> Content + ) { + self.state = state + self.initialEvent = initialEvent + self.env = initialEnv + self.content = content + } + + public var body: some View { + HStack { + if let send { + content(self.state.wrappedValue, Input(send)) + } else { + // transparent placeholder; holds layout until effectManager is ready + Color.clear + .frame(maxWidth: 1, maxHeight: 1) + } + } + .task { + guard self.send == nil else { + return + } + self.send = T.makeSend( + with: Input.self, + storage: self.state, + env: self.env + ) + if let event = initialEvent { + do { + try await Input(send!).send(event) + } catch { + // TODO: improve handling this error + print("initial event failed to be processed") + } + } + } + } +} + + +extension EffectView where Env == Void { + + /// Creates an effect-managed view with no external dependencies. + /// + /// The transducer type and `initialEvent` are captured once when the view + /// appears for the first time. To reset the runtime, recreate the view's + /// identity with `.id(...)`. + /// + /// ```swift + /// EffectView( + /// of: Feature.self, + /// state: $state, + /// ) { state, input in + /// Button("Start") { + /// try? input.post(.start) + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - of: The transducer type. + /// - state: A `Binding` to the view's state, owned by the caller. + /// - initialEvent: An optional event sent when the view first appears. + /// - content: Builds the view from current state and an ``Input`` handle. + public init( + of: T.Type = T.self, + state: Binding, + initialEvent: Event? = nil, + @ViewBuilder content: @escaping (State, Input) -> Content + ) { + self.state = state + self.initialEvent = initialEvent + self.env = () + self.content = content + } +} + +extension SwiftUI.Binding: Storage { + public var value: Value { + get { + self.wrappedValue + } + nonmutating set { + self.wrappedValue = newValue + } + } +} diff --git a/Sources/EffectView/EffectView/EffectViewInput.swift b/Sources/EffectView/EffectView/EffectViewInput.swift new file mode 100644 index 0000000..2200e6a --- /dev/null +++ b/Sources/EffectView/EffectView/EffectViewInput.swift @@ -0,0 +1,164 @@ +/// A `Sendable` handle for dispatching events into the effect engine. +/// +/// `EffectViewInput` provides three dispatch strategies with different semantics: +/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`. +/// - ``post(_:)`` — fire-and-forget; safe from any isolation. +/// - ``request(_:)`` — suspends the caller, returning `Output?`. +/// +/// ### Isolation and lifetime safety +/// +/// 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 EffectViewInput: TransducerInput, Sendable { + + @MainActor + init(_ send: Send) { + self._send = { @MainActor (event, input, continuation) async throws -> Void in + try await send(event, input: input, continuation: continuation) + } + } + + let _send: @MainActor (Event, EffectViewInput, Continuation?) async throws -> Void + + + /// Sends the given event into the transducer. + /// + /// The event will be processed by the transducer's update function, which may + /// return an effect which may itself return an event. This event is synchronously + /// processed by the update function. The chain of events is processed until no + /// further events are returned. + /// + /// If the update function returns a task effect, this task will be started. This + /// also terminates the event processing chain and `send` returns. + /// + /// When `send` returns the transducer has fully processed the event, that is it has + /// updated its state accordingly, and started all effect tasks returned by the + /// update function during processing of the event. However, any async operations + /// in those tasks may continue to run. + /// + /// - seealso: **Effect Operations and Actions** + /// + /// > Note: The send function may suspend only when it executes suspending effect + /// actions. + /// + /// - Parameter event: The event that is sent into the system. + /// - Throws: ``RuntimeUnavailable`` if the runtime cannot accept the event, + /// or `CancellationError` if accepted work is later cancelled. + /// + /// ## Example + /// + /// 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 ``post(_:)`` instead, + /// for example, `input.post(.increment)` — or use the shorthand to post + /// an event: `input(.increment)`. + /// + /// - 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. ``post(_:)`` and ``request(_:)`` are + /// immune because each re-entry is scheduled as a new task, yielding control between iterations. + @MainActor + public func send(_ event: Event) async throws { + try await _send(event, self, nil) + } + + /// 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. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: This implementation does not throw synchronously. Runtime failures + /// surface only inside the scheduled task that later processes the event. + @inline(__always) + public func post(_ event: sending Event) throws { + Task { @MainActor in + try await _send(event, self, nil) + } + } + + /// Sends `event` and suspends until operations of the resulting effect chain complete. + /// + /// The event will be processed by the transducer's update function, which may + /// return an effect which may itself return an event. This event is synchronously + /// processed by the update function. The chain of events is processed until no + /// further events are returned. + /// + /// If the update function returns a task, this task will be executed and `request` will + /// suspend until the task's operation completes, returning the task's output. + /// This also terminates the event processing chain. + /// + /// When `request` returns the transducer has fully processed the event, that is it has + /// updated its state accordingly, and started and awaited the effect task returned + /// by the update function during processing of the event. In the mean time, the + /// transducer can receive and process other events, but the caller is suspended until + /// the effect task triggered by this event has completed. + /// + /// 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, + /// `request` continues to wait until the effect chain settles. + /// - Parameter event: The event to send into the runtime. + /// - Throws: ``RuntimeUnavailable`` when the runtime cannot accept the request, + /// or `CancellationError` if accepted work is later cancelled. + /// - Returns: The terminal `Output?` value produced by the settled effect chain. + /// + /// For usage patterns including `.refreshable`, `task(id:)`, and testing, + /// see . + @discardableResult + public func request( + _ event: Event + ) async throws-> Output? where Output: Sendable, Event: Sendable { + try await withCheckedThrowingContinuation { continuation in + Task { @MainActor in + do { + try await _send(event, self, continuation) + } catch { + continuation.resume(throwing: runtimeBoundaryError(for: error)) + } + } + } + } + + /// Convenience call-as-function syntax for ``post(_:)``. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: Any error that ``post(_:)`` would throw for the same event. + @inline(__always) + public func callAsFunction(_ event: sending Event) throws { + try post(event) + } +} + diff --git a/Sources/EffectView/Input.swift b/Sources/EffectView/Input.swift deleted file mode 100644 index 8e6811d..0000000 --- a/Sources/EffectView/Input.swift +++ /dev/null @@ -1,124 +0,0 @@ - -/// A `Sendable` handle for dispatching events into the effect engine. -/// -/// `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`, 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) { - self._send = send - } - - private var _send: @Sendable @MainActor (Event, Input, CheckedContinuation?) -> Void - - /// 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` 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 - 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 `@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, - /// `request` continues to wait until the effect chain settles. - /// - /// For usage patterns including `.refreshable`, `task(id:)`, and testing, - /// see . - @discardableResult - @MainActor - 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) { - enqueue(event) - } -} diff --git a/Sources/EffectView/ObservationHelpers.swift b/Sources/EffectView/ObservationHelpers.swift deleted file mode 100644 index 8d9ed2e..0000000 --- a/Sources/EffectView/ObservationHelpers.swift +++ /dev/null @@ -1,174 +0,0 @@ -import Observation - -// MARK: - Effect.observe - -extension Effect { - - /// 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/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.request(.countChanged(count)) - /// } - /// ``` - /// - /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `.cancel(name)`. - /// - /// - Parameters: - /// - envKeyPath: Key path from `Env` to the `@Observable` object. The object is held - /// weakly inside the task; the loop exits when it is deallocated. - /// - keyPath: The property on the object to observe. - /// - 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.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( - _ envKeyPath: KeyPath, - keyPath: KeyPath, - name: String? = "observe", - priority: TaskPriority? = nil, - handler: @escaping @MainActor @Sendable (Input, Value) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) - return .task(name: name, priority: priority) { @MainActor input, env in - let object = env[keyPath: envKeyPathBox.keyPath] - await observeKeyPath(object, keyPath: box.keyPath) { value in - await handler(input, value) - } - return nil - } - } - - /// 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/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.request(.countChanged(count)) - /// } - /// ``` - /// - /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `.cancel(name)`. - /// - /// - Parameters: - /// - object: The `@Observable` object to watch. Held weakly inside the task so the - /// effect does not extend the object's lifetime. The loop exits when `object` is - /// deallocated. - /// - keyPath: The property to observe. - /// - 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.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( - _ object: Object, - keyPath: KeyPath, - name: String? = "observe", - priority: TaskPriority? = nil, - handler: @escaping @MainActor @Sendable (Input, Value) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - return .task(name: name, priority: priority) { @MainActor input, _ in - await observeKeyPath(object, keyPath: box.keyPath) { value in - await handler(input, value) - } - return nil - } - } - -} - -// MARK: - Internal helpers - -/// A minimal `@unchecked Sendable` box for `KeyPath`. -/// -/// `KeyPath` is a value type with no mutable state — it is intrinsically safe to share -/// across concurrency domains. This wrapper makes that explicit so key path values can be -/// captured in `@Sendable` closures without requiring `SE-0418` (`InferSendableFromCaptures`) -/// at every call site. -private struct SendableKeyPath: @unchecked Sendable { - let keyPath: KeyPath -} - -/// 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( - _ object: Object, - keyPath: KeyPath, - handler: @escaping @MainActor @Sendable (Value) async -> Void -) async where Object: Observable & AnyObject & Sendable, Value: Sendable { - let box = SendableKeyPath(keyPath: keyPath) - if #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) { - let observations = Observations.untilFinished { [weak object] in - guard let object else { return .finish } - return .next(object[keyPath: box.keyPath]) - } - for await value in observations { - await handler(value) - } - } else { - // Seed the initial value — withObservationTracking only fires on *changes*. - await handler(object[keyPath: box.keyPath]) - _observeKeyPath_legacy(object, keyPath: box, handler: handler) - } -} - -/// Legacy recursive helper for `observeKeyPath` on macOS < 26. -/// -/// `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.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( - _ object: Object, - keyPath box: SendableKeyPath, - handler: @escaping @MainActor @Sendable (Value) async -> Void -) where Object: Observable & AnyObject & Sendable, Value: Sendable { - withObservationTracking { - _ = object[keyPath: box.keyPath] - } onChange: { - guard !Task.isCancelled else { return } - Task { @MainActor [weak object] in - guard let object else { return } - await handler(object[keyPath: box.keyPath]) - _observeKeyPath_legacy(object, keyPath: box, handler: handler) - } - } -} diff --git a/Sources/EffectView/Storage/ReferenceKeyPathStorage.swift b/Sources/EffectView/Storage/ReferenceKeyPathStorage.swift new file mode 100644 index 0000000..c5a4b30 --- /dev/null +++ b/Sources/EffectView/Storage/ReferenceKeyPathStorage.swift @@ -0,0 +1,72 @@ +internal struct ReferenceKeyPathStorage: Storage { + + init(host: Host, keyPath: ReferenceWritableKeyPath) { + self.host = host + self.keyPath = keyPath + } + + private let host: Host + private let keyPath: ReferenceWritableKeyPath + + var value: Value { + get { + host[keyPath: keyPath] + } + nonmutating set { + host[keyPath: keyPath] = newValue + } + } +} + +internal struct WeakReferenceKeyPathStorage: Storage { + + init(host: Host, keyPath: ReferenceWritableKeyPath) { + self.host = host + self.keyPath = keyPath + } + + private weak var host: Host? + private let keyPath: ReferenceWritableKeyPath + + var value: Value { + get { + guard let host = host else { + fatalError( + "Value accessed through weak ReferenceKeyPathStorage has been deallocated.") + } + return host[keyPath: keyPath] + } + nonmutating set { + guard let host = host else { + fatalError( + "Value accessed through weak ReferenceKeyPathStorage has been deallocated.") + } + host[keyPath: keyPath] = newValue + } + } +} + +/// Storage adapter that reads and writes through an unowned reference key path. +/// +/// Use this when the storage host is guaranteed to outlive the adapter and state +/// should be accessed through a reference type rather than copied locally. +public struct UnownedReferenceKeyPathStorage: Storage { + + init(host: Host, keyPath: ReferenceWritableKeyPath) { + self.host = host + self.keyPath = keyPath + } + + private unowned let host: Host + private let keyPath: ReferenceWritableKeyPath + + /// The value stored at `keyPath` on `host`. + public var value: Value { + get { + return host[keyPath: keyPath] + } + nonmutating set { + host[keyPath: keyPath] = newValue + } + } +} diff --git a/Sources/EffectView/Storage/Storage.swift b/Sources/EffectView/Storage/Storage.swift new file mode 100644 index 0000000..0eb8bc1 --- /dev/null +++ b/Sources/EffectView/Storage/Storage.swift @@ -0,0 +1,39 @@ +/// A protocol that abstracts different storage implementations for transducer state. +/// +/// `EffectView` uses `Storage` internally to read and write state through a common +/// interface, whether the backing state lives in local storage, a reference host, +/// or a SwiftUI `Binding`. +/// +/// Most library users will work with higher-level runtime types rather than with +/// `Storage` directly. +public protocol Storage { + associatedtype Value + + /// The current stored value. + var value: Value { get nonmutating set } +} + +internal struct LocalStorage: Storage { + final class Reference { + var value: Value + + init(value: Value) { + self.value = value + } + } + + init(value: Value) { + storage = Reference(value: value) + } + + private let storage: Reference + + var value: Value { + get { + storage.value + } + nonmutating set { + storage.value = newValue + } + } +} diff --git a/Sources/EffectView/Transducer/Errors.swift b/Sources/EffectView/Transducer/Errors.swift new file mode 100644 index 0000000..541733b --- /dev/null +++ b/Sources/EffectView/Transducer/Errors.swift @@ -0,0 +1,49 @@ +import Foundation + +// TODO: we can simplify this. Rename to "CancellationError". CancellationError may have an underlying error specifying the detailed reason. +/// Boundary error indicating that the runtime can no longer accept or complete work. +public enum RuntimeUnavailable: LocalizedError, Equatable, Sendable { + /// The host cancelled the runtime before this call could proceed. + case actorCancelled + + /// The host object was deallocated before this call could enter the runtime. + case actorDeallocated + + /// The runtime latched a critical system failure and stopped accepting work. + case systemError + + /// The current path was cancelled before completion. + case cancelled + + public var errorDescription: String? { + switch self { + case .actorCancelled: + return "The runtime is unavailable because it has already been cancelled." + case .actorDeallocated: + return "The runtime is unavailable because it has already been deallocated." + case .systemError: + return "The runtime is unavailable because it has forcibly terminated because of a critical error." + case .cancelled: + return "The runtime is unavailable because it has been cancelled." + + } + } +} + +func runtimeBoundaryError(for error: any Swift.Error) -> any Swift.Error { + if error is CancellationError { + return error + } + if let runtimeUnavailable = error as? RuntimeUnavailable { + return runtimeUnavailable + } + return RuntimeUnavailable.systemError +} + +enum RuntimeError: Swift.Error { + + // could not perform send, because Send is deallocated + case sendUnavailable + + case noInput +} diff --git a/Sources/EffectView/Transducer/SendFunc.swift b/Sources/EffectView/Transducer/SendFunc.swift new file mode 100644 index 0000000..d7a7b06 --- /dev/null +++ b/Sources/EffectView/Transducer/SendFunc.swift @@ -0,0 +1,152 @@ +/// Low-level runtime handle for routing events and control messages. +/// +/// Most feature code should use higher-level entry points such as +/// ``EffectView/Input-swift.struct`` or ``EffectObservable/Input-swift.struct``. +public struct Send +where Input: TransducerInput & Sendable { + typealias TaggedEvent = EffectView::TaggedEvent + typealias SendFunc = (isolated any Actor, Event, Input?, Continuation?) async throws -> Void + typealias ControlFunc = (isolated any Actor, ControlEvent) throws -> Void + + let send: SendFunc + let control: ControlFunc + + init(send: @escaping SendFunc, control: @escaping ControlFunc) { + self.send = send + self.control = control + } + + /// Sends `event` through the runtime using the provided `input` handle. + /// + /// - Parameters: + /// - systemActor: The actor isolation that owns the runtime. + /// - event: The event to send into the runtime. + /// - input: The input handle forwarded into effect execution. + /// - continuation: An optional request continuation to resume when the chain settles. + /// - Throws: Any runtime error raised while processing the event. + @inline(__always) + public func callAsFunction( + systemActor: isolated any Actor = #isolation, + _ event: Event, + input: Input, + continuation: Continuation? = nil + ) async throws { + try await send(systemActor, event, input, continuation) + } + + func control( + systemActor: isolated any Actor = #isolation, + _ controlEvent: ControlEvent + ) throws { + try control(systemActor, controlEvent) + } +} + +extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { + + /// Creates the low-level `Send` handle used by runtime hosts. + /// NOTE: the implementation may work for global actors - but there could be issues with + /// actor instances since the compute function captures the instance in the closure. + /// + /// - Parameters: + /// - systemActor: The actor isolation that owns the runtime. + /// - input: The `TransducerInput` type to feed into effect execution. + /// - storage: The mutable state storage to reduce events against. + /// - env: The dependency environment captured for runtime work. + /// - Returns: The low-level send handle used to route events and control messages. + public static func makeSend & Sendable, S: Storage>( + systemActor: isolated any Actor = #isolation, + with input: Input.Type = Input.self, + storage: S, + env: Env + ) -> Send + where S.Value == State + { + typealias SendFunc = Send.SendFunc + typealias ControlFunc = Send.ControlFunc + + // Important: a system error is initially created by an operation or + // action when it throws back an error. This error will be caught by + // the EffectManager and then sent through the callback function. + // The callback should map it to a control event and send it into the + // transducer. The transducer then throws in the compute function. + let taskManager = TaskManager() + let gate = ComputeGate() + let sendFunc: SendFunc = { isolator, event, input, continuation in + precondition(systemActor === isolator) + + await gate.enter(systemActor: isolator) + defer { gate.leave(systemActor: isolator) } + + try await compute( + systemActor: isolator, + event: event, + continuation: continuation, + storage: storage, + taskManager: taskManager, + input: input, + env: env + ) + } + + let controlFunc: ControlFunc = { isolator, controlEvent in + precondition(systemActor === isolator) + + try control( + systemActor: isolator, + controlEvent: controlEvent, + storage: storage, + taskManager: taskManager + ) + } + + let send = Send(send: sendFunc, control: controlFunc) + + taskManager.systemErrorCallback = { systemError in + // TaskManager clears this callback when it latches a system error, + // breaking the send/taskManager retain cycle during teardown. + try? send.control(systemActor: systemActor, .systemError(systemError)) + } + + return send + } + +} + +final class ComputeGate { + private var active = false + private var waiters: [CheckedContinuation] = [] + + func enter( + systemActor: isolated any Actor = #isolation + ) async { + if !active { + active = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func leave( + systemActor: isolated any Actor = #isolation + ) { + if waiters.isEmpty { + active = false + return + } + + let next = waiters.removeFirst() + next.resume() + } +} + +// In order to support a "failing" Actor, we need to wrap the Send function +// into an async "run" function. `run` could use withCheckedThrowingContinuation +// and pass the continuation into the compute function. With control events, +// we can resume the continuation. Then, the async throwing run function can +// be put into a Task. `run` also has a task cancellation handler installed which +// sends a control event to the actor, so that the transducer can cancel and +// terminate. diff --git a/Sources/EffectView/Transducer/TaskManager.swift b/Sources/EffectView/Transducer/TaskManager.swift new file mode 100644 index 0000000..a64b965 --- /dev/null +++ b/Sources/EffectView/Transducer/TaskManager.swift @@ -0,0 +1,477 @@ + +/// Coordinates keyed async tasks and their waiting continuations for one runtime. +/// +/// `TaskManager` tracks tasks by logical identifier rather than only by task +/// instance. Equal identifiers mean equal in-flight work. When a new waiter +/// arrives for an identifier that already has an active task, the manager uses +/// ``TaskExecutionOption`` to decide whether the waiter subscribes to the +/// existing task or replaces it with a fresh task. +/// +/// The manager also defines the runtime's hard-stop semantics. Once +/// ``cancel(with:)`` begins cancellation, the manager rejects new tasks and +/// cancels tracked work. Graceful teardown is intentionally not part of this +/// type. A controlled shutdown must be modeled in transducer state and event +/// flow instead. +final class TaskManager { + + /// A waiter suspended on a task result managed by this instance. + typealias Continuation = CheckedContinuation + + /// Describes whether the manager is accepting work or shutting down. + enum State { + /// The manager accepts new tasks and waiters. + case active + /// Cancellation has begun and the manager has latched an optional error. + case cancelling(error: Swift.Error? = nil) + /// Cancellation has fully completed and the optional error remains latched. + case cancelled(error: Swift.Error? = nil) + } + + private var tasks: Dictionary = [:] + private var taskId: Int = 0 // a monotonic increasing integer used as a unique identifier for a task. + private(set) var state: State = .active + + var systemErrorCallback: ((any Swift.Error) async -> Void)? = nil + + /// Optional callback for surfacing a fatal system error back to the runtime owner. + init(systemErrorCallback: ((any Swift.Error) async -> Void)? = nil) { + self.systemErrorCallback = systemErrorCallback + #if DEBUG + print("EffectManager: init") + #endif + } + + deinit { + #if DEBUG + print("EffectManager: deinit") + #endif + let shutdownError = latchedShutdownError + tasks.values.forEach { taskValue in + var taskValue = taskValue + taskValue.cancel(with: shutdownError) + } + } + + /// Throws if the manager is no longer accepting work. + /// + /// Callers typically use this as a boundary check before entering the main + /// runtime loop. When the manager has latched a concrete shutdown error, + /// that error is rethrown. Otherwise `RuntimeUnavailable.cancelled` is + /// thrown. + /// + /// - Throws: The latched shutdown error, or `RuntimeUnavailable.cancelled` + /// when cancellation happened without a more specific reason. + @inline(__always) + func checkCancellation() throws { + switch state { + case .active: break + case .cancelling(let error), .cancelled(let error): + if let error { + throw error + } else { + throw RuntimeUnavailable.cancelled + } + } + } + + /// Starts hard cancellation of the manager and its tracked tasks. + /// + /// The first call latches `error`, transitions the manager out of the + /// active state, clears ``systemErrorCallback``, and cancels all tracked + /// tasks. Later calls are ignored. + /// + /// - Parameter error: An optional shutdown reason to latch for later + /// ``checkCancellation()`` calls. + func cancel(with error: (any Swift.Error)? = nil) { + guard case .active = self.state else { + return + } + self.state = .cancelling(error: error) + // Break the send/taskManager retain cycle once the runtime has irreversibly failed. + systemErrorCallback = nil + + for taskKey in Array(tasks.keys) { + tasks[taskKey]?.cancel(with: error) + } + if tasks.isEmpty { + state = .cancelled(error: error) + } + } + + private var latchedShutdownError: any Swift.Error { + switch state { + case .active: + return RuntimeUnavailable.cancelled + case .cancelling(let error), .cancelled(let error): + return error ?? RuntimeUnavailable.cancelled + } + } + + /// Cancels the tracked task for `identifier`, if one exists. + /// + /// All waiters currently attached to that task are resumed with + /// `CancellationError()`. + /// + /// - Parameter identifier: The logical identifier of the task to cancel. + /// - Returns: `true` if an active tracked task was found and cancelled. + @discardableResult + func cancelTasks( + systemActor: isolated any Actor = #isolation, + with identifier: TaskIdentifier + ) -> Bool { + let taskKey = TaskKey(identifier) + if var taskValue = tasks[taskKey] { + if !taskValue.task.isCancelled { + taskValue.cancel() + tasks[taskKey] = taskValue + #if DEBUG + print("EffectManager cancelled task: \(identifier)-\(tasks[taskKey]!.id)") + #endif + return true + } else { + #if DEBUG + print("EffectManager did not cancel task with identifier \"\(identifier)\" - because it is already cancelled") + #endif + return false + } + } else { + #if DEBUG + print("EffectManager could not cancel task with identifier \"\(identifier)\" - not in tasks") + #endif + return false + } + } + + /// Adds a task or waiter to the manager. + /// + /// If the manager is still active, the task identified by `identifier` is + /// either reused or replaced according to `option`. When `continuation` is + /// non-`nil`, it is attached to the active task chosen for that identifier. + /// + /// If the manager is no longer active, `continuation` is resumed with the + /// latched shutdown error, or `RuntimeUnavailable.cancelled` when shutdown + /// happened without a more specific reason, and no new task is started. + /// + /// - Parameters: + /// - systemActor: The isolation the system is executing on. + /// - identifier: The logical task identity. Equal identifiers mean equal + /// overlapping work. + /// - option: Decides whether a new waiter reuses the active task or replaces it. + /// - continuation: If not `nil`, a waiter which will be resumed when the current + /// active task for this identifier completes. + /// - priority: The priority of the operation task. + /// - isolatedOperation: The operation to perform. + func addTask( + systemActor: isolated any Actor = #isolation, + with identifier: TaskIdentifier? = nil, + option: TaskExecutionOption = .switchToLatest, + continuation: Continuation?, + priority: TaskPriority? = nil, + isolatedOperation: @escaping (isolated any Actor) async throws -> Output? + ) { + // TODO: check if this should be better a precondition + guard case .active = state else { + if let continuation { + continuation.resume(throwing: latchedShutdownError) + } + return + } + switch option { + case .switchToLatest: + var continuations: [Continuation] = [] + if let taskIdentifier = identifier { + continuations = cancelForReplacement(identifier: taskIdentifier) + } + if let continuation { + continuations.append(continuation) + } + addNewTask( + identifier: identifier, + continuations: continuations, + priority: priority, + isolatedOperation: isolatedOperation + ) + + case .subscribe: + if let identifier, + let continuation, + var taskValue = tasks[TaskKey(identifier)] { + // add subscriber (aka waiter) to the existing tracked task, even if it + // has already been cancelled but has not completed yet. + taskValue.subscribe(continuation: continuation) + tasks[TaskKey(identifier)] = taskValue + } else { + let continuations = continuation.map { [$0] } ?? [] + addNewTask( + identifier: identifier, + continuations: continuations, + priority: priority, + isolatedOperation: isolatedOperation + ) + } + } + } + + /// Cancels the current task for replacement and returns its waiter set. + /// + /// `.switchToLatest` replaces the task instance but preserves the waiter set + /// by moving those continuations onto the replacement task. + @discardableResult + private func cancelForReplacement(identifier: TaskIdentifier) -> [Continuation] { + let taskKey = TaskKey(identifier) + guard var taskValue = tasks[taskKey] else { + return [] + } + // Keep the waiter set; `.switchToLatest` replaces the task instance, not the waiters. + let continuations = taskValue.cancelForReplacement() + tasks[taskKey] = taskValue + #if DEBUG + print("EffectManager cancelled task for replacement: \(identifier)-\(taskValue.id)") + #endif + return continuations + } + + /// Inserts a fresh tracked task under `identifier`. + /// + /// The new task captures `systemActor`, runs `isolatedOperation`, and then + /// either resumes the attached waiters with the operation result or begins + /// manager cancellation when the operation fails with a non-task-cancellation + /// error. + private func addNewTask( + systemActor: isolated any Actor = #isolation, + identifier: TaskIdentifier?, + continuations: [Continuation], + priority: TaskPriority?, + isolatedOperation: @escaping (isolated any Actor) async throws -> Output? + ) { + let taskKey: TaskKey + let id = taskId + if let identifier = identifier { + taskKey = TaskKey(identifier) + } else { + taskKey = TaskKey.makeAnon(with: id) + } + // CAUTION: `systemActor` is captured *strongly*!. In cases, where the + // systemActor keeps a strong reference to `self`, self will never be + // deallocated before all tasks are finished, because the captured + // `systemActor` establishes a reference cycle - until after the task + // finishes. This is important to know when implementing an "FSM Effect + // Actor" based on Swift Actors. That is, a proper implementation of an + // "FSM Effect Actor" should always have a `cancel()` method which cancells + // all running tasks and additionally prevents enqueueing new ones. + let task = Task(name: taskKey.string, priority: priority) { [weak self] in + _ = systemActor + let result: Result + do { + let output = try await isolatedOperation(systemActor) + result = .success(output) + } catch { + result = .failure(error) + } + switch result { + case .failure(let error): + // TODO: Triple check: Does a CancellationError not mean a "system error"?? + // We only have a CancellationError that is not a "system error" when + // this task cancellation was explicitly caused by the transducer. That is: + // the managed task is itself cancelled, or in this context: + // if `error is CancellationError && Task.isCancelled` equals true. + // Thus, a "system error" can be a CancellationError as well. + // Now, the code is implemented (fixed) accordingly - but also: + // TODO: assert this in a dedicated unit test + if error is CancellationError && Task.isCancelled { + self?.finish(taskKey: taskKey, id: id, result: result) + } else { + if let systemErrorCallback = self?.systemErrorCallback { + await systemErrorCallback(error) + } else { + self?.cancel(with: error) + } + self?.complete(taskKey: taskKey, id: id) + } + case .success: + self?.finish(taskKey: taskKey, id: id, result: result) + } + } + + let taskValue = TaskValue(id: id, task: task, continuations: continuations) + taskId += 1 + tasks[taskKey] = taskValue + + #if DEBUG + print("EffectManager added Task: \(taskKey)-\(taskValue.id)") + #endif + } + + /// Resumes all waiters for the matching task and removes it from tracking. + private func finish(taskKey: TaskKey, id: Int, result: Result) { + if var taskValue = tasks[taskKey], taskValue.id == id { + taskValue.resume(with: result) + tasks[taskKey] = taskValue + } + complete(taskKey: taskKey, id: id) + } + + /// Removes the tracked task if `id` still matches the current entry. + private func complete(taskKey: TaskKey, id: Int) { + if let taskValue = tasks[taskKey], taskValue.id == id { + precondition(taskValue.continuations.isEmpty) + tasks[taskKey] = nil + } else { + // Currently, with TaskKey being hashed on the identifier, + // this can happen, when a subsequent task cancels the previous + // one (aka `switchToLatest`), and the previous task has not + // been completed (and removed) *before* the new taks has been + // inserted into the ductionary with the *same* key. When previous + // taks eventually completes, it there's no entry with its `id` + // anymore. + /* nothing */ + } + if tasks.isEmpty, case .cancelling(let error) = state { + state = .cancelled(error: error) + } + #if DEBUG + print("EffectManager task completed: \(taskKey.string)-\(id)") + #endif + } + +} + +extension TaskManager { + + /// The dictionary key used to track a logical task. + struct TaskKey: Hashable, Equatable, CustomStringConvertible { + init(_ identifier: TaskIdentifier) { + self.identifier = identifier + } + + static func makeAnon(with taskId: Int) -> TaskKey { + return .init(TaskIdentifier("__\(taskId)")) + } + + let identifier: TaskIdentifier? + + var description: String { string } + var string: String { "\(identifier, default: "__")" } + } + + /// The mutable tracked value for one logical task entry. + struct TaskValue { + let id: Int // unique task id + let task: Task + var continuations: [Continuation] + + init(id: Int, task: Task, continuations: [Continuation]) { + self.id = id + self.task = task + self.continuations = continuations + } + + /// Cancels the task and fails all current waiters with `error`, or with + /// `CancellationError()` when no more specific reason is available. + mutating func cancel(with error: (any Swift.Error)? = nil) { + task.cancel() + for continuation in continuations { + continuation.resume(throwing: error ?? CancellationError()) + } + continuations = [] + } + + /// Completes all current waiters with the finished task result. + mutating func resume(with result: Result) { + for continuation in continuations { + switch result { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let output): + continuation.resume(returning: output) + } + } + continuations = [] + } + + /// Attaches a new waiter to the tracked task. + mutating func subscribe(continuation: Continuation) { + continuations.append(continuation) + } + + /// Cancels the task for `.switchToLatest` while preserving its waiters. + mutating func cancelForReplacement() -> [Continuation] { + task.cancel() + let continuations = continuations + self.continuations = [] + return continuations + } + } + +} + +/// A typed logical identifier for managed tasks. +/// +/// Equal `TaskIdentifier` values declare the same in-flight work. The +/// task manager uses that identity to decide whether a new task request should +/// subscribe to existing work or replace it. +public struct TaskIdentifier: @unchecked Sendable, Hashable { + private let wrapped: AnyHashable + + /// Creates an identifier from any hashable, sendable value. + /// + /// - Parameter wrapped: The logical identifier value to wrap. + public init(_ wrapped: some Hashable & Sendable) { + self.wrapped = .init(wrapped) + } +} + +extension TaskIdentifier: ExpressibleByStringLiteral { + /// Creates an identifier from a string literal. + /// + /// - Parameter stringLiteral: The string value to wrap as an identifier. + public init(stringLiteral: String) { + self.init(stringLiteral) + } +} + +extension TaskIdentifier: ExpressibleByIntegerLiteral { + /// Creates an identifier from an integer literal. + /// + /// - Parameter value: The integer value to wrap as an identifier. + public init(integerLiteral value: IntegerLiteralType) { + self.init(value) + } +} + +extension TaskIdentifier: ExpressibleByStringInterpolation {} + + +extension TaskIdentifier: CustomStringConvertible { + /// Human-readable representation of the identifier. + public var description: String { string } + + /// The identifier rendered as a string. + public var string: String { wrapped.description } +} + + +/// Controls how the runtime handles a new request for an identifier that already has +/// an active task. +/// +/// Equal task identifiers declare the same logical in-flight work. The option decides +/// whether the runtime reuses the current task instance or replaces it with a fresh one. +/// +/// A later request only competes with work that is still active for the same logical +/// identifier. Once the tracked task has completed, the next request starts fresh +/// regardless of which option was used previously. +public enum TaskExecutionOption { + /// Cancel the running task for this identifier, start a fresh task, and attach all + /// current waiters plus the new waiter to the replacement task. + /// + /// - > Caution: `switchToLatest` replaces the physical task instance but preserves + /// waiter ownership. Existing waiters do not fail merely because a replacement + /// starts; they move to the new current task for the same identifier. + case switchToLatest + + /// Keep the running task for this identifier and add the new waiter to it. + /// + /// Later callers share the current logical work and receive the same terminal + /// result or failure as the active task for that identifier. + case subscribe +} diff --git a/Sources/EffectView/Transducer/Transducer.Effects.swift b/Sources/EffectView/Transducer/Transducer.Effects.swift new file mode 100644 index 0000000..946b208 --- /dev/null +++ b/Sources/EffectView/Transducer/Transducer.Effects.swift @@ -0,0 +1,343 @@ +extension Transducer where Effect == TransducerEffect { + + /// Returns an effect which when invoked starts an async throwing operation isolated to a global actor + /// 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 identifier. + /// + /// - Important: Managed cancellation takes precedence over racing task failures. + /// If the runtime cancels a tracked task and the operation concurrently throws, + /// the effect engine may classify that outcome as cancellation rather than as a + /// system error. This is intentional: once a task has been superseded or + /// cancelled by the runtime, late failures from that obsolete work no longer + /// participate in global error escalation. + /// + /// Prefer ``run(id:priority:option:operation:)`` for fire-and-forget tasks and + /// ``request(id:priority:option:operation:)`` for perform-driven tasks rather than + /// constructing `.task` directly. + /// + /// - Parameters: + /// - id: An optional identifier 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. + /// - option: Defines how overlapping waiters for the same `id` are handled. + /// `.subscribe` keeps the running task and attaches the new waiter to it. + /// `.switchToLatest` cancels the running task, starts a fresh one, and moves + /// all current waiters for that identifier onto the replacement task. + /// - operation: The async work to perform. Returns an optional `Output` value + /// forwarded to any caller suspended on ``Input/request(_:)``. + /// + /// - Returns: The effect. + // TODO: Need to exaplain clearly what it means, when an operation throws. + // Usually, operations shouls not fail, but in some cases, the operation may use + // an input to send events back to the system and *this* input can fail due to a "system error". System + // errors are critical errors - that is, it might mean, the actor is deallocated, + // or a potential event buffer did overflow or some other system error occured, + // That means, the transducer is not guaranteed to perform correctly anymoer. The + // best course of action is to tear down the transducer and actor, and forward + // the error to event senders and waiters. + @inline(__always) + public static func task( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + option: TaskExecutionOption = .switchToLatest, + operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? + ) -> Effect { + ._task(id: id, priority: priority, option: option, operation: operation) + } + + /// Returns an effect which when invoked starts an async throwing operation isolated to the system actor + /// tracked by the effect engine. + /// + /// The `isolatedOperation` 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 identifier. + /// + /// - Important: Managed cancellation takes precedence over racing task failures. + /// If the runtime cancels a tracked task and the operation concurrently throws, + /// the effect engine may classify that outcome as cancellation rather than as a + /// system error. This is intentional: once a task has been superseded or + /// cancelled by the runtime, late failures from that obsolete work no longer + /// participate in global error escalation. + /// + /// Prefer ``run(id:priority:option:operation:)`` for fire-and-forget tasks and + /// ``request(id:priority:option:operation:)`` for perform-driven tasks rather than + /// constructing `.task` directly. + /// + /// - Parameters: + /// - id: An optional identifier 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. + /// - option: Defines how overlapping waiters for the same `id` are handled. + /// `.subscribe` keeps the running task and attaches the new waiter to it. + /// `.switchToLatest` cancels the running task, starts a fresh one, and moves + /// all current waiters for that identifier onto the replacement task. + /// - isolatedOperation: The async work to perform. Returns an optional `Output` value + /// forwarded to any caller suspended on ``Input/request(_:)``. + /// + /// - Returns: An effect. + @inline(__always) + public static func task( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + option: TaskExecutionOption = .switchToLatest, + isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Output? + ) -> Effect { + ._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: isolatedOperation) + } + + /// Return an effect which when invoked executes 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 system actor before any other work proceeds. + /// + /// Unlike named tasks, actions do not participate in overlap management. If a + /// caller is suspended on ``Input/request(_:)``, the caller simply waits until the + /// synchronous action chain terminates or reaches a terminal task. + /// + /// - Parameter action: A synchronous closure receiving `Env` and returning an + /// optional next event. + /// + /// - Warning: Action chains unwind entirely on the system actor. + /// A cycle — two events that each produce an `.action` pointing back at the other — + /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work + /// that could repeat or loop. + /// + /// - Returns: An effect. + @inline(__always) + public static func action( + _ action: @escaping (Env) -> Event? + ) -> Effect { + ._actionSync(action) + } + + /// Return an effect which when invoked executes an async step on a user specified global actor + /// 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 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` and returning an + /// optional next event. + /// + /// - Warning: Action chains unwind entirely on the system actor without yielding. + /// A cycle — two events that each produce an `.action` pointing back at the other — + /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work + /// that could repeat or loop. + /// + /// - Returns: An effect. + @inline(__always) + public static func action( + _ action: @escaping @Sendable @isolated(any) (Env) async -> sending Event? + ) -> Effect { + ._actionAsync(action) + } + + /// Return an effect which when invoked executes an async step on the system actor + /// 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 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` and returning an + /// optional next event. + /// + /// - Warning: Action chains unwind entirely on the system actor without yielding. + /// A cycle — two events that each produce an `.action` pointing back at the other — + /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work + /// that could repeat or loop. + /// + /// - Returns: An effect. + @inline(__always) + public static func action( + _ action: @escaping (Env, isolated any Actor) async -> sending Event? + ) -> Effect { + ._actionAsyncIsolated(action) + } + + /// Returns an effect which when invoked feeds `event` back into `update` immediately, in + /// the current synchronous turn. + /// + /// - Parameter event: The next event to feed directly back into `update`. + /// - Returns: An effect. + @inline(__always) + public static func event(_ event: Event) -> Effect { + ._event(event) + } + + /// Returns an effect which cancels the running task with the given identifier, if any. + /// + /// - Parameter id: The logical task identifier to cancel. + /// - Returns: An effect. + @inline(__always) + public static func cancel(_ id: TaskIdentifier) -> Effect { + ._cancel(id) + } + + /// Returns an effect which contains a sequence of effects. The effects will be executed + /// from 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()]) + /// ``` + /// + /// - 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. + /// + /// - Parameter effects: The ordered effects to execute from left to right. + /// - Returns: An effect. + @inline(__always) + public static func sequence(_ effects: [Effect]) -> Effect { + ._sequence(effects) + } +} + + +extension Transducer where Effect == TransducerEffect { + + /// Returns an effect which, when invoked, 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. + /// + /// Managed cancellation follows ``task(id:priority:option:operation:)`` semantics: + /// if the runtime cancels this task, that cancellation takes precedence over any + /// racing late failure from the operation. + /// + /// ```swift + /// return .run(id: "ticker") { input, env in + /// do { + /// while true { + /// try await env.clock.sleep(for: .seconds(1)) + /// input(.tick) + /// } + /// } catch {} + /// } + /// ``` + /// + /// - Parameters: + /// - id: Optional logical identifier for tracking and overlap policy. + /// - priority: Optional `TaskPriority` for the launched task. + /// - option: The overlap policy to apply when another task with the same + /// identifier is started. + /// - operation: The fire-and-forget async work to perform. + /// - Returns: An effect. + @inline(__always) + public static func run( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + option: TaskExecutionOption = .switchToLatest, + operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Void + ) -> Effect where Env: Sendable { + ._task(id: id, priority: priority, option: option) { input, env in + await operation(input, env) + return nil + } + } + + /// Returns an effect which, when invoked, 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. + /// + /// Managed cancellation follows ``task(id:priority:option:isolatedOperation:)`` semantics: + /// if the runtime cancels this task, that cancellation takes precedence over any + /// racing late failure from the operation. + /// + /// ```swift + /// return .run(id: "ticker") { input, env in + /// do { + /// while true { + /// try await env.clock.sleep(for: .seconds(1)) + /// input(.tick) + /// } + /// } catch {} + /// } + /// ``` + /// + /// - Parameters: + /// - systemActor: The host actor isolation forwarded into `isolatedOperation`. + /// - id: Optional logical identifier for tracking and overlap policy. + /// - priority: Optional `TaskPriority` for the launched task. + /// - option: The overlap policy to apply when another task with the same + /// identifier is started. + /// - isolatedOperation: The fire-and-forget async work to perform on the host actor. + /// - Returns: An effect. + @inline(__always) + public static func run( + systemActor: isolated (any Actor)? = #isolation, + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + option: TaskExecutionOption = .switchToLatest, + isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async -> Void + ) -> Effect where Env: Sendable { + ._taskIsolated(id: id, priority: priority, option: option) { input, env, isolation in + precondition( + systemActor != nil && systemActor === isolation, + "taskIsolated requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." + ) + await isolatedOperation(input, env, isolation) + 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. + /// + /// Managed cancellation follows ``task(id:priority:option:operation:)`` semantics: + /// if the runtime cancels this task, that cancellation takes precedence over any + /// racing late failure from the operation. + /// + /// ```swift + /// return .request(id: "load") { input, env in + /// let user = await env.api.fetchUser() + /// return await input.request(.loaded(user)) + /// } + /// ``` + /// + /// - Parameters: + /// - id: Optional logical identifier for tracking and overlap policy. + /// - priority: Optional `TaskPriority` for the launched task. + /// - option: The overlap policy to apply when another task with the same + /// identifier is started. + /// - operation: The async work whose terminal result resumes the original waiter. + /// - Returns: An effect. + @inline(__always) + public static func request( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + option: TaskExecutionOption = .switchToLatest, + operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Output? + ) -> Effect { + ._task(id: id, priority: priority, option: option, operation: operation) + } + +} diff --git a/Sources/EffectView/Transducer/Transducer.run.swift b/Sources/EffectView/Transducer/Transducer.run.swift new file mode 100644 index 0000000..872eb3e --- /dev/null +++ b/Sources/EffectView/Transducer/Transducer.run.swift @@ -0,0 +1,37 @@ +extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { + + /// Runs the transducer runtime directly with an explicit low-level send handle. + /// + /// This API is intended as the low-level entry point beneath higher-level hosts such + /// as ``EffectView`` and ``EffectObservable``. + /// + /// - Warning: This entry point is currently a stub and always throws + /// ``RunError/notImplemented``. + /// - Parameters: + /// - systemActor: The actor isolation that owns the runtime. + /// - send: The low-level send handle used to route events and control messages. + /// - initialState: The starting state for the runtime. + /// - input: The input handle to expose to effect execution. + /// - Throws: ``RunError/notImplemented``. + /// - Returns: The terminal `Output?` value once the runtime settles. + @discardableResult + public static func run & Sendable>( + systemActor: isolated any Actor = #isolation, + send: Send, + initialState: State, + input: Input + ) async throws -> Output? { + _ = send + _ = initialState + _ = input + throw RunError.notImplemented + } +} + +// TODO: when implemente, remove it +/// Placeholder error for the unfinished low-level ``Transducer/run(systemActor:send:initialState:input:)`` API. +public enum RunError: Error, Sendable { + /// The requested runtime entry point has not been implemented yet. + case notImplemented +} + diff --git a/Sources/EffectView/Transducer/Transducer.swift b/Sources/EffectView/Transducer/Transducer.swift new file mode 100644 index 0000000..4da4e68 --- /dev/null +++ b/Sources/EffectView/Transducer/Transducer.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Finite-state reducer contract for the effect runtime. +/// +/// A `Transducer` defines the domain model that `EffectView` or +/// `EffectObservable` hosts: mutable `State`, incoming `Event`s, optional +/// dependency `Env`, and the ``TransducerEffect`` values returned from +/// ``update(_:event:)``. +/// +/// The runtime treats ``update(_:event:)`` as the single mutation point. +/// `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`. +public protocol Transducer { + /// Mutable feature state owned by the host runtime. + associatedtype State + + /// Domain event type that drives state transitions. + associatedtype Event + + /// Value returned to callers suspended on `request`-style entry points. + /// + /// Use `Void` when the feature does not return a result. + associatedtype Output = Void + + /// Dependency environment captured for the runtime lifetime. + /// + /// Use `Void` when the feature has no external dependencies. + associatedtype Env = Void + + /// Effect type returned from ``update(_:event:)``. + associatedtype Effect = TransducerEffect + + /// Applies `event` to `state` and returns the next effect to execute. + /// + /// `update` is synchronous. Mutate `state` directly and return an optional + /// effect describing any follow-up work. Return `nil` when processing ends + /// with no further effect. + /// + /// - Parameters: + /// - state: The current mutable feature state. + /// - event: The incoming event to reduce. + /// - Returns: The next effect to execute, or `nil` if processing terminates. + static func update(_ state: inout State, event: Event) -> Effect? + + /// Produces the terminal result for a settled request-style event chain. + /// + /// The runtime calls `output` when a `request` reaches a terminal state + /// without handing its continuation off to a managed task. + /// + /// - Parameters: + /// - state: The final state after the event chain has settled. + /// - event: The terminal event that ended the chain. + /// - Returns: The value to resume the waiting request with. + static func output(state: State, event: Event) -> Output +} + +/// Continuation used internally to complete request-style callers. +public typealias Continuation = CheckedContinuation + + +extension Transducer where Output == Void { + /// Default terminal output for features that do not return a value. + /// + /// - Parameters: + /// - state: The final state after the event chain has settled. + /// - event: The terminal event that ended the chain. + /// - Returns: `Void`. + @inline(__always) + public static func output(state: State, event: Event) -> Output { () } +} + + +enum ControlEvent: Sendable { + case systemError(any Swift.Error) + case cancel +} + +enum TaggedEvent { + case event(Event) + case control(ControlEvent) +} + +extension TaggedEvent: Sendable where Event: Sendable {} + +enum SystemCompletion: Swift.Error { + case error(any Swift.Error) + case cancelled +} + + +// Note: A Transducer requires an isolation in order to compile! +extension Transducer where Effect == TransducerEffect { + + // Caution: control should not mutate storage! + static func control( + systemActor: isolated any Actor = #isolation, + controlEvent: ControlEvent, + storage: some Storage, + taskManager: TaskManager + ) throws where Output: Sendable, Env: Sendable { + var error: Swift.Error? = nil + switch controlEvent { + case .systemError(let systemError): + error = systemError + taskManager.cancel(with: error) + case .cancel: + taskManager.cancel(with: error) + } + try taskManager.checkCancellation() + } + + + // TODO: document clearly when and why this function throws. + // Note: *ideally* it should not throw + + /// Processes a regular event through `update` until the chain terminates. + /// + /// > Important: On normal return, `continuation` has been fully consumed: it was either + /// resumed during synchronous processing or handed off to `taskManager` for + /// later completion. If this function throws, it does not resume the + /// continuation; the caller must handle the thrown error and decide how the + /// waiting request should complete. + static func compute>( + systemActor: isolated any Actor = #isolation, + event: Event, + continuation: Continuation?, + storage: some Storage, + taskManager: TaskManager, + input: Input?, + env: Env + ) async throws where Output: Sendable, Env: Sendable, Input: Sendable { + var nextEvent: Event? = event + var cont = continuation + while let event = nextEvent { + try taskManager.checkCancellation() + nextEvent = nil + if let effect = update(&storage.value, event: event) { + (nextEvent, cont) = try await executeEffect( + effect, + continuation: cont, + taskManager: taskManager, + input: input, + env: env + ) + } else { + if let continuation { + let output = output(state: storage.value, event: event) + continuation.resume(returning: output) + } + cont = nil + } + } + assert(cont == nil) + } + + // TODO: document clearly when and why this function throws. + // Note: *ideally* it should not throw. + // Note: it seems, the only case when it throws is a kind of precondition. + private static func executeEffect>( + systemActor: isolated any Actor = #isolation, + _ effect: Effect, + continuation: Continuation?, + taskManager: TaskManager, + input: Input?, + env: Env + ) async throws -> (Event?, Continuation?) where Output: Sendable, Env: Sendable, Input: Sendable { + switch effect { + case ._task(id: let identifier, priority: let priority, let option, operation: let operation): + guard let input else { + // TODO: Check if this should be better a precondition + throw RuntimeError.noInput + } + taskManager.addTask( + with: identifier, + option: option, + continuation: continuation, + priority: priority, + isolatedOperation: { _ in + try await operation(input, env) + } + ) + return (nil, nil) + + case ._taskIsolated(id: let identifier, priority: let priority, let option, isolatedOperation: let isolatedOperation): + guard let input else { + // TODO: Check if this should be better a precondition + throw RuntimeError.noInput + } + taskManager.addTask( + with: identifier, + option: option, + continuation: continuation, + priority: priority, + isolatedOperation: { isolated in + _ = systemActor + return try await isolatedOperation(input, env, isolated) + } + ) + return (nil, nil) + + case ._event(event: let event): + return (event, continuation) + + case ._actionSync(action: let action): + let event = action(env) + if event == nil { + continuation?.resume(returning: nil) + return (nil, nil) + } + return (event, continuation) + + case ._actionAsync(action: let action): + let event = await action(env) + try taskManager.checkCancellation() + + if event == nil { + continuation?.resume(returning: nil) + return (nil, nil) + } + return (event, continuation) + + case ._actionAsyncIsolated(action: let action): + let event = await action(env, systemActor) + try taskManager.checkCancellation() + + if event == nil { + continuation?.resume(returning: nil) + return (nil, nil) + } + return (event, continuation) + + case ._cancel(let identifier): + taskManager.cancelTasks(with: identifier) + continuation?.resume(returning: nil) + return (nil, nil) + + case ._sequence(let effects): + guard let last = effects.last else { + continuation?.resume(returning: nil) + return (nil, nil) + } + for effect in effects.dropLast() { + _ = try await executeEffect( + effect, + continuation: nil, + taskManager: taskManager, + input: input, + env: env + ) + } + return try await executeEffect( + last, + continuation: continuation, + taskManager: taskManager, + input: input, + env: env + ) + + case .none: + return (nil, nil) + } + } +} diff --git a/Sources/EffectView/Transducer/TransducerEffect.swift b/Sources/EffectView/Transducer/TransducerEffect.swift new file mode 100644 index 0000000..e048bbb --- /dev/null +++ b/Sources/EffectView/Transducer/TransducerEffect.swift @@ -0,0 +1,90 @@ + +/// A value describing a side effect to run after a state transition. +/// +/// `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 +/// // Fire-and-forget task: +/// return .run(id: "ticker") { input, env in +/// while true { +/// try await env.clock.sleep(for: .seconds(1)) +/// input(.tick) +/// } +/// } +/// +/// // Perform-driven task (caller awaits result): +/// return .request(id: "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 +/// } +/// ``` +/// +/// - Caution: Effects should only be created on the system actor. +/// - Important: Prefer the documented factory helpers such as +/// ``Transducer/run(id:priority:option:operation:)``, +/// ``Transducer/request(id:priority:option:operation:)``, +/// ``Transducer/action(_:)``, ``Transducer/event(_:)``, and +/// ``Transducer/cancel(_:)`` rather than constructing underscored +/// enum cases directly. The underscored cases are the runtime +/// representation. +/// +/// ### 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. +// TODO: Need to exaplain clearly what it means, when an operation throws. +// Usually, operations shouls not fail, but in some cases, the operation may use +// an input to send events back to the system and *this* input can fail due to a "system error". System +// errors are critical errors - that is, it might mean, the actor is deallocated, +// or a potential event buffer did overflow or some other system error occured, +// That means, the transducer is not guaranteed to perform correctly anymoer. The +// best course of action is to tear down the transducer and actor, and forward +// the error to event senders and waiters. +public enum TransducerEffect { + + case none + + case _task( + id: TaskIdentifier?, + priority: TaskPriority?, + option: TaskExecutionOption, + operation: @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? + ) + + case _taskIsolated( + id: TaskIdentifier?, + priority: TaskPriority?, + option: TaskExecutionOption, + isolatedOperation: (any TransducerInput, Env, isolated any Actor) async throws -> Output? + ) + + case _actionSync( + (Env) -> Event? + ) + + case _actionAsync( + @isolated(any) (Env) async -> sending Event? + ) + + case _actionAsyncIsolated( + (Env, isolated any Actor) async -> sending Event? + ) + + case _event(Event) + + case _cancel(TaskIdentifier) + + case _sequence([TransducerEffect]) +} + diff --git a/Sources/EffectView/Transducer/TransducerInput.swift b/Sources/EffectView/Transducer/TransducerInput.swift new file mode 100644 index 0000000..74cb88c --- /dev/null +++ b/Sources/EffectView/Transducer/TransducerInput.swift @@ -0,0 +1,59 @@ + +/// A handle for feeding events back into a transducer runtime. +/// +/// `TransducerInput` has two dispatch styles: +/// +/// - ``post(_:)`` sends an event without awaiting a result. +/// - ``request(_:)`` suspends until the triggered effect chain settles and returns +/// the terminal `Output?` value, if any. +/// +/// When multiple `request` calls overlap and eventually drive a named task with the +/// same identifier, the task's ``TaskExecutionOption`` decides how the runtime treats +/// the active task for that identifier: +/// +/// - `.subscribe`: keep the running task and add the new waiter to it. +/// - `.switchToLatest`: cancel the running task, start a fresh one, and move all +/// current waiters for that identifier onto the replacement task. +/// +/// Equal task identifiers therefore mean more than "same cancellation key": they +/// declare the same logical in-flight work. Overlapping waiters for one identifier +/// must converge to one current result or one current error. +public protocol TransducerInput { + associatedtype Event + associatedtype Output + + /// Sends `event` without awaiting a result. + /// + /// If `post` throws, that failure is local to the call site. It only becomes a + /// critical runtime error if the caller lets it escape from inside an effect + /// closure and the error is thereby fed back into the system. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: A runtime entry failure if the implementation cannot accept the event. + func post(_ event: sending Event) throws + + /// Sends `event` and suspends until the resulting effect chain settles. + /// + /// If the chain reaches a named task, overlapping waiters for the same task + /// identifier are coalesced according to that task's ``TaskExecutionOption``. + /// The caller is waiting for the current active task for that identifier, not + /// necessarily for the first physical task instance that was started. + /// + /// - Parameter event: The event to send into the runtime. + /// - Throws: A runtime entry failure if the request cannot start, or a later + /// cancellation or runtime failure while the request is in flight. + @discardableResult + func request(_ event: Event) async throws -> Output? +} + +extension TransducerInput { + + /// Convenience call-as-function syntax for ``post(_:)``. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: Any error that ``post(_:)`` would throw for the same event. + @inline(__always) + public func callAsFunction(_ event: sending Event) throws { + try post(event) + } +} diff --git a/Sources/EffectView/EnvReader.swift b/Sources/EffectView/Utilities/EnvReader.swift similarity index 100% rename from Sources/EffectView/EnvReader.swift rename to Sources/EffectView/Utilities/EnvReader.swift diff --git a/Tests/EffectViewTests/AsyncActionRuntimeTests.swift b/Tests/EffectViewTests/AsyncActionRuntimeTests.swift new file mode 100644 index 0000000..26fd0cd --- /dev/null +++ b/Tests/EffectViewTests/AsyncActionRuntimeTests.swift @@ -0,0 +1,179 @@ +import Testing +@testable import EffectView + +#if canImport(Observation) + +@Suite("Async action runtime") +@MainActor +struct AsyncActionRuntimeTests { + + actor AsyncGate: Sendable { + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume() + } + } + } + + enum AsyncActionCancellationTransducer: Transducer { + struct State: Equatable { + var phases: [String] = [] + } + + enum Event: Sendable { + case start + case finished + } + + struct Env: Sendable { + let started: Expectation + let release: AsyncGate + } + + typealias Output = String + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + state.phases.append("start") + return action { env in + env.started.fulfill() + await env.release.wait() + return .finished + } + + case .finished: + state.phases.append("finished") + return nil + } + } + + static func output(state: State, event: Event) -> String { + state.phases.joined(separator: ",") + } + } + + enum GatedAsyncActionTransducer: Transducer { + struct State: Equatable { + var events: [String] = [] + } + + enum Event: Sendable { + case first + case firstFinished + case second + } + + struct Env: Sendable { + let firstStarted: Expectation + let releaseFirst: AsyncGate + } + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .first: + state.events.append("first") + return action { env in + env.firstStarted.fulfill() + await env.releaseFirst.wait() + return .firstFinished + } + + case .firstFinished: + state.events.append("firstFinished") + return nil + + case .second: + state.events.append("second") + return nil + } + } + } + + @Test func requestThrowsRuntimeUnavailableWhenCancelledDuringAsyncAction() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let started = Expectation() + let release = AsyncGate() + let timeout: UInt64 = 5_000_000_000 + + let observable = EffectObservable( + initialState: .init(), + env: .init(started: started, release: release) + ) + + let waiter = Task { + try await observable.request(.start) + } + + try await started.await(nanoseconds: timeout) + #expect(observable.state.phases == ["start"]) + + observable.cancel() + await Task.yield() + + await release.open() + + do { + _ = try await waiter.value + Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") + } catch let error as RuntimeUnavailable { + #expect(error == .actorCancelled) + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + #expect(observable.state.phases == ["start"]) + } + + @Test func concurrentSendWaitsForEarlierAsyncActionToFinish() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let firstStarted = Expectation() + let releaseFirst = AsyncGate() + let timeout: UInt64 = 5_000_000_000 + + let observable = EffectObservable( + initialState: .init(), + env: .init(firstStarted: firstStarted, releaseFirst: releaseFirst) + ) + + let firstSend = Task { + try await observable.send(.first) + } + + try await firstStarted.await(nanoseconds: timeout) + + let secondSend = Task { + try await observable.send(.second) + } + + await Task.yield() + await Task.yield() + + #expect(observable.state.events == ["first"]) + + await releaseFirst.open() + + try await firstSend.value + try await secondSend.value + + #expect(observable.state.events == ["first", "firstFinished", "second"]) + } +} + +#endif \ No newline at end of file diff --git a/Tests/EffectViewTests/EffectViewTests.swift b/Tests/EffectViewTests/EffectViewTests.swift index b5ff07f..c168dc9 100644 --- a/Tests/EffectViewTests/EffectViewTests.swift +++ b/Tests/EffectViewTests/EffectViewTests.swift @@ -3,6 +3,9 @@ import Foundation import Testing import SwiftUI @testable import EffectView +#if canImport(Observation) +import Observation +#endif #if canImport(UIKit) import UIKit @@ -21,133 +24,68 @@ import AppKit @MainActor struct EffectViewTests { - // MARK: - TestView wrapper - - /// Owns @State so the Binding that flows into EffectView is live. - struct TestView: View { - @SwiftUI.State private var state: State - private let content: (Binding) -> Content - - init(initialState: State, @ViewBuilder content: @escaping (Binding) -> Content) { - self._state = .init(initialValue: initialState) - self.content = content - } - - var body: some View { content($state) } - } - - // MARK: - Platform abstractions - - #if canImport(UIKit) - typealias HostingController = UIHostingController - typealias PlatformWindow = UIWindow - #elseif canImport(AppKit) - typealias HostingController = NSHostingController - typealias PlatformWindow = NSWindow - #endif - - // MARK: - Helpers - - struct EmbedInWindowAndMakeKeyTimeoutError: Error {} - - /// Wraps `view` in a hosting controller, makes the window key, and suspends - /// until every `onAppear` in the view hierarchy has fired. - func embedInWindowAndMakeKey(_ view: V, timeout: TimeInterval = 1.0) async throws -> (HostingController, PlatformWindow) { - var hostingController: HostingController? - var window: PlatformWindow? - var isResumed = false - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let timeoutCancelTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000 + 0.5)) - guard isResumed == false else { return } - isResumed = true - continuation.resume(throwing: EmbedInWindowAndMakeKeyTimeoutError()) - } - hostingController = HostingController( - rootView: AnyView( - view.onAppear { - // Defer one run-loop cycle so child onAppear calls (which fire - // breadth-first) have also completed before we resume. - DispatchQueue.main.async { - guard isResumed == false else { return } - timeoutCancelTask.cancel() - isResumed = true - continuation.resume() - } - } - ) - ) - #if canImport(UIKit) - window = UIWindow() - window!.rootViewController = hostingController - window!.makeKeyAndVisible() - #elseif canImport(AppKit) - window = NSWindow(contentViewController: hostingController!) - window!.makeKeyAndOrderFront(nil) - #endif - } - return (hostingController!, window!) - } - - func cleanup(_ window: PlatformWindow) { - #if canImport(UIKit) - window.isHidden = true - #elseif canImport(AppKit) - window.orderOut(nil) - #endif - } - // MARK: - Lifecycle @Test func contentAppearsExactlyOnce() async throws { - enum Event: Sendable { case dummy } - struct State: Equatable { var x = 0 } - + enum T: Transducer { + enum Event: Sendable { case dummy } + struct State: Equatable { var x = 0 } + static func update(_ state: inout State, event: Event) -> Effect? { + nil + } + } + var appearCount = 0 - let view = TestView(initialState: State()) { binding in - EffectView(state: binding, update: { _, _ -> Effect? in nil }) { _, _ in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, _ in Color.clear.onAppear { appearCount += 1 } } + } expect: { + #expect(appearCount == 1, "content onAppear should fire exactly once on first render") } - - let (_, window) = try await embedInWindowAndMakeKey(view) - #expect(appearCount == 1, "content onAppear should fire exactly once on first render") - cleanup(window) } @Test func initialStateIsPreserved() async throws { - struct State: Equatable { var label: String } - enum Event: Sendable { case dummy } + enum T: Transducer { + struct State: Equatable { var label: String } + enum Event: Sendable { case dummy } + static func update(_ state: inout State, event: Event) -> Effect? { + nil + } + } var capturedLabel: String? - let view = TestView(initialState: State(label: "custom")) { binding in - EffectView(state: binding, update: { _, _ -> Effect? in nil }) { state, _ in + try await testView(initialState: T.State(label: "custom")) { binding in + EffectView(of: T.self, state: binding) { state, _ in Color.clear.onAppear { capturedLabel = state.label } } + } expect: { + #expect(capturedLabel == "custom") } - - let (_, window) = try await embedInWindowAndMakeKey(view) - #expect(capturedLabel == "custom") - cleanup(window) } // MARK: - State updates @Test func updateIsCalledAndStatePropagates() async throws { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } + enum T: Transducer { + struct State: Equatable { var count = 0 } + enum Event: Sendable { case increment } + static func update(_ state: inout State, event: Event) -> Effect? { + state.count += 1 + return nil + } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? var observedValues: [Int] = [] let expectation = Expectation() let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State()) { binding in + try await testView(initialState: T.State()) { binding in EffectView( - state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } + of: T.self, + state: binding ) { state, input in Text("\(state.count)") .onAppear { @@ -159,32 +97,35 @@ struct EffectViewTests { expectation.fulfill() } } + } expect: { + #expect(observedValues == [0]) + #expect(capturedInput != nil) + try await capturedInput?.send(.increment) + try await expectation.await(nanoseconds: timeout) + #expect(observedValues == [0, 1]) } - - let (_, window) = try await embedInWindowAndMakeKey(view) - #expect(observedValues == [0]) - #expect(capturedInput != nil) - capturedInput?.send(.increment) - try await expectation.await(nanoseconds: timeout) - #expect(observedValues == [0, 1]) - cleanup(window) } @Test func stateChangeTriggersRerender() async throws { - enum State: Equatable, Sendable { case off, on } - enum Event: Sendable { case toggle } + enum T: Transducer { + enum State: Equatable, Sendable { case off, on } + enum Event: Sendable { case toggle } + static func update(_ state: inout State, event: Event) -> Effect? { + state = (state == .off ? .on : .off); return nil + } + } class RenderCounter: @unchecked Sendable { var count = 0 } let counter = RenderCounter() let expectation = Expectation() - var capturedInput: Input? + var capturedInput: EffectViewInput? let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State.off) { binding in + try await testView(initialState: T.State.off) { binding in EffectView( - state: binding, - update: { state, _ -> Effect? in state = (state == .off ? .on : .off); return nil } + of: T.self, + state: binding ) { state, input in Text(state == .on ? "on" : "off") .onAppear { @@ -196,14 +137,12 @@ struct EffectViewTests { expectation.fulfill() } } + } expect: { + let countAfterMount = counter.count + try await capturedInput?.send(.toggle) + try await expectation.await(nanoseconds: timeout) + #expect(counter.count > countAfterMount, "View should re-render after state change") } - - let (_, window) = try await embedInWindowAndMakeKey(view) - let countAfterMount = counter.count - capturedInput?.send(.toggle) - try await expectation.await(nanoseconds: timeout) - #expect(counter.count > countAfterMount, "View should re-render after state change") - cleanup(window) } // MARK: - initialEvent @@ -211,220 +150,290 @@ struct EffectViewTests { @Test func initialEventFiresOnAppear() async throws { // The initial event fires synchronously inside EffectView's .task, in the same // run-loop pass as the input setup. SwiftUI batches both state mutations into a - // single re-render, so onChange never sees a transition. Instead we capture every - // event that reaches `update` in a log and assert on it after onAppear fires. - class EventLog: @unchecked Sendable { var events: [Event] = [] } - enum Event: Sendable, Equatable { case start } - struct State: Equatable {} - - let log = EventLog() + // single re-render, so onChange never sees a transition. We record every event + // in State and assert on it after onAppear fires. + enum T: Transducer { + enum Event: Sendable, Equatable { case start } + struct State: Equatable { var events: [Event] = [] } + static func update(_ state: inout State, event: Event) -> Effect? { + // Note: update with the initial event will be called before + // onAppear will be called + state.events.append(event) + return nil + } + } - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - initialEvent: .start, - update: { _, event -> Effect? in - // Note: update with the initial event will be called before - // onAppear will be called - log.events.append(event) - return nil - } - ) { _, _ in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding, initialEvent: .start) { state, _ in Color.clear.onAppear { - #expect(log.events == [.start], "initialEvent should be processed before content onAppear fires") + #expect(state.events == [.start], "initialEvent should be processed before content onAppear fires") } } + } expect: { } - - let (_, window) = try await embedInWindowAndMakeKey(view) - cleanup(window) } // MARK: - request @Test func requestSuspendsUntilUpdateCompletes() async throws { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } + enum T: Transducer { + struct State: Equatable { var count = 0 } + enum Event: Sendable { case increment } + static func update(_ state: inout State, event: Event) -> Effect? { state.count += 1; return nil } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } - ) { _, input in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, input in Color.clear.onAppear { capturedInput = input } } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + // Each request() suspends until the update loop has processed the event. + // Three sequential requests must complete without deadlock or timeout. + try await input.request(.increment) + try await input.request(.increment) + try await input.request(.increment) } - - let (_, window) = try await embedInWindowAndMakeKey(view) - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - // 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) } @Test func multipleEventsProcessedInOrder() async throws { - struct State: Equatable { var log: [Int] = [] } - enum Event: Sendable { case record(Int) } - - class LogCapture: @unchecked Sendable { var entries: [Int] = [] } - let captured = LogCapture() - - var capturedInput: Input? - let doneExpectation = Expectation() - - let timeout: UInt64 = 5_000_000_000 - - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - update: { state, event -> Effect? in - if case .record(let n) = event { state.log.append(n) } - return nil - } - ) { state, input in + enum T: Transducer { + struct State { var log: [Int] = [] } + enum Event: Sendable { case record(Int) } + typealias Output = [Int] + static func update(_ state: inout State, event: Event) -> Effect? { + if case .record(let n) = event { state.log.append(n) } + return nil + } + + static func output(state: State, event: Event) -> [Int] { + state.log + } + } + + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in Text("\(state.log.count)") .onAppear { capturedInput = input } - .onChange(of: state.log) { newLog in - captured.entries = newLog - if newLog.count == 5 { doneExpectation.fulfill() } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + var outputs: [[Int]] = [] + + // request() guarantees each update completes before the next event is sent. + for i in 1...5 { + outputs.append(try await input.request(.record(i)) ?? []) + } + + #expect(outputs == [ + [1], + [1, 2], + [1, 2, 3], + [1, 2, 3, 4], + [1, 2, 3, 4, 5], + ]) + } + } + + @Test func requestReturnsOutputFromTaskClosure() async throws { + enum T: Transducer { + struct State: Equatable { var value: String = "" } + enum Event: Sendable { case load, loaded(String) } + typealias Output = String + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return request(id: "load") { input, _ in + // Simulate async work, aka a service function. If it was + // successful, fire a completion event which updates + // state. If it fails, send a corresponding failure event. + // Note: If we throw within an effect closure, we feed + // this error back into the system which is considered + // a "system error". It preferred to handle the error or + // to send a corresponding error event back. + do { + try await Task.sleep(for: .milliseconds(1)) // simulate remote work + } catch { + // not handled here in the test. + // Production code should send a service error, for + // example: `let output try await input.request(Event.serviceFailed(error)) ` + // then return `output`. + } + let result = "hello" + let output = try? await input.request(Event.loaded(result)) // drives state; return discarded + return output // this becomes the Output? } + case .loaded(let v): + state.value = v + return nil + } + } + static func output(state: State, event: Event) -> String { + state.value } } - let (_, window) = try await embedInWindowAndMakeKey(view) - guard let input = capturedInput else { Issue.record("Input not captured"); return } + var capturedInput: EffectViewInput? - // 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) + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + let output = try await input.request(.load) + #expect(output == "hello") + } } - @Test func requestReturnsOutputFromTaskClosure() async throws { - struct State: Equatable { var value: String = "" } - enum Event: Sendable { case load, loaded(String) } - typealias Output = String + @Test func requestThrowsLatchedSystemErrorInsteadOfHanging() async throws { + enum TestError: Error, Equatable { + case boom + } - var capturedInput: Input? + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load } + typealias Output = String - 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 + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return task(id: "load") { _, _ in + throw TestError.boom } } - ) { _, input in - Color.clear.onAppear { capturedInput = input } + } + + static func output(state: State, event: Event) -> String { + "" } } - let (_, window) = try await embedInWindowAndMakeKey(view) - guard let input = capturedInput else { Issue.record("Input not captured"); return } + var capturedInput: EffectViewInput? + let completion = Expectation() + let timeout: UInt64 = 5_000_000_000 - let output = await input.request(.load) - #expect(output == "hello") - cleanup(window) + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + do { + _ = try await input.request(.load) + Issue.record("Expected first request to receive the task failure") + } catch let error as TestError { + #expect(error == .boom) + } catch { + Issue.record("Unexpected first request error: \(error)") + } + + let secondRequest = Task { + do { + _ = try await input.request(.load) + Issue.record("Expected second request to throw RuntimeUnavailable.runtimeFailed") + } catch let error as RuntimeUnavailable { + #expect(error == .systemError) + } catch { + Issue.record("Unexpected second request error: \(error)") + } + completion.fulfill() + } + + try await completion.await(nanoseconds: timeout) + _ = await secondRequest.result + } } // MARK: - Effects @Test func taskEffectRunsAndMutatesState() async throws { - struct State: Equatable { var loaded = false } - enum Event: Sendable { case load, didLoad } + enum T: Transducer { + struct State: Equatable { var loaded = false } + enum Event: Sendable { case load, didLoad } + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return task(id: "fetch") { input, _ in try input.post(Event.didLoad) } + case .didLoad: + state.loaded = true + return nil + } + } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? let loadedExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - update: { state, event -> Effect? in - switch event { - case .load: - return .task(name: "fetch") { input, _ in input.enqueue(.didLoad) } - case .didLoad: - state.loaded = true - return nil - } - } - ) { state, input in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in Text(state.loaded ? "loaded" : "idle") .onAppear { capturedInput = input } .onChange(of: state.loaded) { _ in loadedExpectation.fulfill() } } + } expect: { + try await capturedInput?.send(.load) + try await loadedExpectation.await(nanoseconds: timeout) } - - let (_, window) = try await embedInWindowAndMakeKey(view) - capturedInput?.send(.load) - try await loadedExpectation.await(nanoseconds: timeout) - cleanup(window) } @Test func cancelEffectStopsRunningTask() async throws { - struct State: Equatable { var ticks = 0; var running = false } - enum Event: Sendable { case start, tick, stop } + enum T: Transducer { + struct State: Equatable { var ticks = 0; var running = false } + enum Event: Sendable { case start, tick, stop } + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + state.running = true + return task(id: "ticker") { input, _ in + do { + // run infinitely - or until "ticker" tasks gets cancelled + while true { + try await Task.sleep(nanoseconds: 20_000_000) // 20 ms + try input.post(Event.tick) + } + } catch { + print("Error: \(error)") + /* task cancelled — exit cleanly */ + } + } + case .tick: + state.ticks += 1 + return nil + case .stop: + state.running = false + return cancel("ticker") + } + } + } class TickCounter: @unchecked Sendable { var count = 0 } let tickCounter = TickCounter() - var capturedInput: Input? + var capturedInput: EffectViewInput? let twoTicksExpectation = Expectation(minFulfillCount: 2) let stoppedExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - update: { state, event -> Effect? in - switch event { - case .start: - state.running = true - return .task(name: "ticker") { input, _ in - do { - // run infinitely - or until "ticker" tasks gets cancelled - while true { - try await Task.sleep(nanoseconds: 20_000_000) // 20 ms - input.enqueue(.tick) - } - } catch { /* task cancelled — exit cleanly */ } - } - case .tick: - state.ticks += 1 - return nil - case .stop: - state.running = false - return .cancel("ticker") - } - } - ) { state, input in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in Text("\(state.ticks)") .onAppear { capturedInput = input @@ -437,43 +446,41 @@ struct EffectViewTests { if !isRunning { stoppedExpectation.fulfill() } } } + } expect: { + #expect(capturedInput != nil) + + try await capturedInput?.send(.start) + try await twoTicksExpectation.await(nanoseconds: timeout) + try await capturedInput?.send(.stop) + + try await stoppedExpectation.await(nanoseconds: timeout) + let countAtStop = tickCounter.count + + // Wait 3x the tick interval - any in-flight ticks would arrive within this window. + try await Task.sleep(nanoseconds: 60_000_000) // 60 ms + #expect(tickCounter.count == countAtStop, "No ticks should arrive after cancel") } - - let (_, window) = try await embedInWindowAndMakeKey(view) - #expect(capturedInput != nil) - - capturedInput?.send(.start) - try await twoTicksExpectation.await(nanoseconds: timeout) - capturedInput?.send(.stop) - - try await stoppedExpectation.await(nanoseconds: timeout) - let countAtStop = tickCounter.count - - // Wait 3x the tick interval - any in-flight ticks would arrive within this window. - try await Task.sleep(nanoseconds: 60_000_000) // 60 ms - #expect(tickCounter.count == countAtStop, "No ticks should arrive after cancel") - cleanup(window) } @Test func actionEffectChainFiresSynchronously() async throws { - struct State: Equatable { var phase = 0 } - enum Event: Sendable { case begin, step, done } + enum T: Transducer { + struct State: Equatable { var phase = 0 } + enum Event: Sendable { case begin, step, done } + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .begin: state.phase = 1; return action { _ in Event.step } + case .step: state.phase = 2; return action { _ in Event.done } + case .done: state.phase = 3; return nil + } + } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? let readyExpectation = Expectation() let doneExpectation = Expectation() - let view = TestView(initialState: State()) { binding in - EffectView( - state: binding, - 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 } - case .done: state.phase = 3; return nil - } - } - ) { state, input in + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in Text("\(state.phase)") .onAppear { capturedInput = input @@ -483,52 +490,56 @@ struct EffectViewTests { if phase == 3 { doneExpectation.fulfill() } } } + } expect: { + try await readyExpectation.await(nanoseconds: 5_000_000_000) + + // request() awaits the entire synchronous chain: begin → step → done. + try await capturedInput?.request(.begin) + try await doneExpectation.await(nanoseconds: 5_000_000_000) } - - let (_, window) = try await embedInWindowAndMakeKey(view) - try await readyExpectation.await(nanoseconds: 5_000_000_000) - - // request() awaits the entire synchronous chain: begin → step → done. - await capturedInput?.request(.begin) - try await doneExpectation.await(nanoseconds: 5_000_000_000) - cleanup(window) } @Test func sequenceEffectCancelsThenStartsTask() async throws { - struct State: Equatable { var ticks = 0 } - enum Event: Sendable { case startFirst, refresh, tick } + struct WorkerEnv: Sendable { let cancelExpectation: Expectation; let timeout: UInt64 } + enum T: Transducer { + struct State: Equatable { var ticks = 0 } + enum Event: Sendable { case startFirst, refresh, tick } + typealias Env = WorkerEnv + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .startFirst: + // Long-running task that never ticks on its own. + return task(id: "worker") { input, env in + do { + try await Task.sleep(nanoseconds: env.timeout) + } catch { + env.cancelExpectation.fulfill() + } + } + case .refresh: + // Cancel stale worker, immediately start a fresh one that ticks. + return sequence([ + cancel("worker"), + task(id: "worker") { input, _ in try input.post(Event.tick) }, + ]) + case .tick: + state.ticks += 1 + return nil + } + } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? let tickExpectation = Expectation() let cancelExpectation = Expectation() - + let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State()) { binding in + try await testView(initialState: T.State()) { binding in EffectView( + of: T.self, state: binding, - update: { (state, event) -> Effect? in - switch event { - case .startFirst: - // Long-running task that never ticks on its own. - return .task(name: "worker") { input, _ in - do { - try await Task.sleep(nanoseconds: timeout) - } catch { - cancelExpectation.fulfill() - } - } - case .refresh: - // Cancel stale worker, immediately start a fresh one that ticks. - return .sequence([ - .cancel("worker"), - .task(name: "worker") { input, _ in input.enqueue(.tick) }, - ]) - case .tick: - state.ticks += 1 - return nil - } - } + initialEnv: WorkerEnv(cancelExpectation: cancelExpectation, timeout: timeout) ) { state, input in Text("\(state.ticks)") .onAppear { @@ -538,35 +549,32 @@ struct EffectViewTests { tickExpectation.fulfill() } } + } expect: { + try await capturedInput?.send(.startFirst) + try await capturedInput?.send(.refresh) // cancels first task, starts new one that ticks + try await cancelExpectation.await(nanoseconds: timeout) + try await tickExpectation.await(nanoseconds: timeout) } - - let (_, window) = try await embedInWindowAndMakeKey(view) - - capturedInput?.send(.startFirst) - capturedInput?.send(.refresh) // cancels first task, starts new one that ticks - try await cancelExpectation.await(nanoseconds: timeout) - try await tickExpectation.await(nanoseconds: timeout) - cleanup(window) } // MARK: - Identity reset @Test func identityResetRestoresInitialState() async throws { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } + enum T: Transducer { + struct State: Equatable { var count = 0 } + enum Event: Sendable { case increment } + static func update(_ state: inout State, event: Event) -> Effect? { state.count += 1; return nil } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? let resetExpectation = Expectation() var countsOnAppear: [Int] = [] let timeout: UInt64 = 5_000_000_000 let (hostingController, window) = try await embedInWindowAndMakeKey( - TestView(initialState: State()) { binding in - EffectView( - state: binding, - update: { state, _ -> Effect? in state.count += 1; return nil } - ) { _, input in + TestView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, input in Color.clear.onAppear { capturedInput = input } @@ -576,16 +584,13 @@ struct EffectViewTests { guard let input = capturedInput else { Issue.record("Input not captured"); return } - await input.request(.increment) - await input.request(.increment) + try await input.request(.increment) + try 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 } - ) { state, _ in + TestView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, _ in Color.clear.onAppear { countsOnAppear.append(state.count) resetExpectation.fulfill() @@ -602,30 +607,34 @@ struct EffectViewTests { // MARK: - Env @Test func envIsForwardedToTaskOperation() async throws { - struct State: Equatable { var result = "" } - enum Event: Sendable { case fetch, loaded(String) } - struct Env: Sendable { var value: String } + struct TaskEnv: Sendable { var value: String } + enum T: Transducer { + struct State: Equatable { var result = "" } + enum Event: Sendable { case fetch, loaded(String) } + typealias Env = TaskEnv + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .fetch: + return task(id: "fetch") { input, env in + try input.post(Event.loaded(env.value)) + } + case .loaded(let value): + state.result = value + return nil + } + } + } - var capturedInput: Input? + var capturedInput: EffectViewInput? let loadedExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 - let view = TestView(initialState: State()) { binding in + try await testView(initialState: T.State()) { binding in EffectView( + of: T.self, state: binding, - initialEnv: Env(value: "hello from env"), - update: { state, event -> Effect? in - switch event { - case .fetch: - return .task(name: "fetch") { input, env in - input.enqueue(.loaded(env.value)) - } - case .loaded(let value): - state.result = value - return nil - } - } + initialEnv: TaskEnv(value: "hello from env") ) { state, input in Text(state.result) .onAppear { @@ -633,19 +642,172 @@ struct EffectViewTests { } .onChange(of: state.result) { _ in loadedExpectation.fulfill() } } + } expect: { + try await capturedInput?.send(.fetch) + try await loadedExpectation.await(nanoseconds: timeout) } + } - let (_, window) = try await embedInWindowAndMakeKey(view) - capturedInput?.send(.fetch) - try await loadedExpectation.await(nanoseconds: timeout) - cleanup(window) + // MARK: - Observation + + #if canImport(Observation) + + // Shared observable type for observation tests. Defined at member scope because + // @Observable (an extension macro) cannot be applied to local types. + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + @Observable final class ObservableCounter: @unchecked Sendable { var value = 0 } + + // Holds a weak reference without triggering the "weak var never mutated" warning. + private final class WeakBox: @unchecked Sendable { + weak var object: T? + init(_ v: T) { self.object = v } + } + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + @Test func observeDoesNotRetainObservable() async throws { + // Effect.observe(object:keyPath:) documents that the object is held weakly. + // Verify that dropping all external strong references deallocates the observed + // object even while the EffectView is still live. + enum T: Transducer { + struct State: Equatable { var latest = -1 } + enum Event: Sendable { case watch(ObservableCounter), tick(Int) } + static func update(_ state: inout State, event: Event) -> TransducerEffect? { + switch event { + case .watch(let c): + return observe(c, keyPath: \.value) { input, v in + // Regarding: using observe with isolated action + // Note: request is *nonisolated* for this Input. Thus we + // cannot use `isolatedOperation` - we need to have + // a Sendable operation which also requires Input to be + // sendable! + try? await input.request(.tick(v)) + } + case .tick(let v): + state.latest = v + return nil + } + } + } + + var counter: ObservableCounter? = ObservableCounter() + let weakBox = WeakBox(counter!) + var capturedInput: EffectViewInput? + let firstTickExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in + Color.clear + .onAppear { capturedInput = input } + .onChange(of: state.latest) { _ in firstTickExpectation.fulfill() } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + // Start observation. The taskIsolated closure captures the counter strongly + // only until observeKeyPath returns (after the initial handler call); then the + // task completes and releases it. Subsequent onChange callbacks use a WeakObject. + try await input.send(.watch(counter!)) + try await firstTickExpectation.await(nanoseconds: timeout) + + // Yield to let the observe task finalise and release its captured reference. + await Task.yield() + await Task.yield() + + // Drop the only remaining strong reference. ARC should free the object. + counter = nil + await Task.yield() + + #expect(weakBox.object == nil, + "Effect.observe must not retain the observable beyond the initial task") + } } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @MainActor + @Test func cancelObservationTaskStopsHandlerInvocations() async throws { + // Verify that .cancel("observe") prevents further handler calls. + // After cancellation, mutating the observable must not deliver new values. + struct ObsEnv: Sendable { let counter: ObservableCounter } + class InvocationLog: @unchecked Sendable { var count = 0 } + let log = InvocationLog() + + enum T: Transducer { + struct State: Equatable { var latest = -1 } + enum Event: Sendable { case start, stop, tick(Int) } + typealias Env = ObsEnv + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + return observe(\.counter, keyPath: \.value, id: "observe") { input, value in + try? await input.request(.tick(value)) + print("request(.tick(\(value))) finished") + } + case .stop: + return cancel("observe") + case .tick(let v): + state.latest = v + return nil + } + } + } + + let counter = ObservableCounter() + var capturedInput: EffectViewInput? + let firstTickExpectation = Expectation() + let secondTickExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: ObsEnv(counter: counter) + ) { state, input in + Color.clear + .onAppear { capturedInput = input } + .onChange(of: state.latest) { newValue in + log.count += 1 + if log.count == 1 { firstTickExpectation.fulfill() } + if log.count == 2 { secondTickExpectation.fulfill() } + } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + // Start observing; the initial value (0) is delivered via state. + // Caution: DO NOT use `request` for a observation task, because it won't finishe before it gets cancelled! + try input.post(.start) + try await firstTickExpectation.await(nanoseconds: timeout) + + // Mutate the counter; the handler should fire once more. + counter.value = 1 + try await secondTickExpectation.await(nanoseconds: timeout) + let countAtCancel = log.count // expected: 2 + + // Cancel the observation task. + try await input.request(.stop) + + // Allow any last in-flight handler task a chance to drain. + try await Task.sleep(nanoseconds: 50_000_000) // 50 ms + + // Further mutations must not trigger additional handler calls. + counter.value = 2 + counter.value = 3 + try await Task.sleep(nanoseconds: 50_000_000) // 50 ms + + #expect(log.count == countAtCancel, + "No handler calls expected after cancelling the observation task") + } + } + + #endif } #else import Testing -@Suite("EffectView (SwiftUI unavailable)") +// @Suite("EffectView (SwiftUI unavailable)") struct EffectViewTests { @Test func skipped() { // Hosted tests require SwiftUI + AppKit or UIKit. @@ -665,6 +827,23 @@ private final class EventSpy: @unchecked Sendable { var received: [Event] = [] } +private struct TaskInput: TransducerInput, Sendable { + let onEvent: @Sendable @MainActor (Event) -> Void + + func post(_ event: sending Event) { + Task { @MainActor in + onEvent(event) + } + } + + func request(_ event: Event) async -> Output? { + await MainActor.run { + onEvent(event) + return nil + } + } +} + // MARK: - Counter model (no Env) private struct CounterState: Equatable { @@ -679,7 +858,7 @@ private enum CounterEvent: Equatable, Sendable { private func counterUpdate( state: inout CounterState, event: CounterEvent -) -> Effect? { +) -> TransducerEffect? { switch event { case .increment: state.count += 1 @@ -692,12 +871,12 @@ private func counterUpdate( return nil case .start: state.running = true - return .task(name: "ticker") { input, _ in - input.enqueue(.ticked) + return ._task(id: "ticker", priority: nil, option: .switchToLatest) { input, _ in + try input.post(.ticked) } case .stop: state.running = false - return .cancel("ticker") + return ._cancel("ticker") case .ticked: state.count += 1 return nil @@ -728,17 +907,17 @@ private struct LoadFetchError: Error, LocalizedError { private func loaderUpdate( state: inout LoaderState, event: LoaderEvent -) -> Effect? { +) -> TransducerEffect? { switch event { case .load: state.isLoading = true state.error = nil - return .task(name: "fetch") { input, env in + return ._task(id: "fetch", priority: nil, option: .switchToLatest) { input, env in do { let items = try await env.fetch() - input.enqueue(.loaded(items)) + try input.post(.loaded(items)) } catch { - input.enqueue(.failed(error.localizedDescription)) + try input.post(.failed(error.localizedDescription)) } } case .loaded(let items): @@ -811,7 +990,7 @@ struct EffectTypeTests { var state = CounterState() let effect = counterUpdate(state: &state, event: .start) #expect(state.running == true) - guard case .task(name: let name, _, _) = effect, name == "ticker" else { + guard case ._task(id: let name, _, _, _) = effect, name == "ticker" else { Issue.record(#"Expected .task(name: "ticker")"#) return } @@ -821,7 +1000,7 @@ struct EffectTypeTests { var state = CounterState(count: 0, running: true) let effect = counterUpdate(state: &state, event: .stop) #expect(state.running == false) - guard case .cancel(let name) = effect else { + guard case ._cancel(let name) = effect else { Issue.record("Expected .cancel") return } @@ -831,7 +1010,7 @@ struct EffectTypeTests { @Test func loadReturnsNamedFetchTask() { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case .task(name: let name, _, _) = effect, name == "fetch" else { + guard case ._task(id: let name, _, _, _) = effect, name == "fetch" else { Issue.record(#"Expected .task(name: "fetch")"#) return } @@ -839,8 +1018,8 @@ struct EffectTypeTests { @Test func actionEffectInvokesClosureAndReturnsEvent() { enum Ev: Equatable, Sendable { case a, b } - let effect = Effect.action { _ in .b } - guard case .action(let run) = effect else { + let effect = TransducerEffect._actionSync { _ in .b } + guard case ._actionSync(let run) = effect else { Issue.record("Expected .action") return } @@ -849,8 +1028,8 @@ struct EffectTypeTests { @Test func actionEffectCanReturnNil() { enum Ev: Equatable, Sendable { case a } - let effect = Effect.action { _ in nil } - guard case .action(let run) = effect else { + let effect = TransducerEffect._actionSync { _ in nil } + guard case ._actionSync(let run) = effect else { Issue.record("Expected .action") return } @@ -859,19 +1038,19 @@ struct EffectTypeTests { @Test func sequenceContainsOrderedEffects() { enum Ev: Equatable, Sendable { case done } - let effect = Effect.sequence([ - .cancel("old"), - .task(name: "new") { _, _ in } + let effect = TransducerEffect._sequence([ + ._cancel("old"), + ._task(id: "new", priority: nil, option: .switchToLatest) { _, _ in } ]) - guard case .sequence(let effects) = effect, effects.count == 2 else { + guard case ._sequence(let effects) = effect, effects.count == 2 else { Issue.record("Expected .sequence with 2 effects") return } - guard case .cancel("old") = effects[0] else { + guard case ._cancel("old") = effects[0] else { Issue.record(#"Expected effects[0] to be .cancel("old")"#) return } - guard case .task(name: let name, _, _) = effects[1], name == "new" else { + guard case ._task(id: let name, _, _, _) = effects[1], name == "new" else { Issue.record(#"Expected effects[1] to be .task(name: "new")"#) return } @@ -883,7 +1062,7 @@ struct EffectTypeTests { /// These tests extract the operation closure from a returned `.task` effect and /// drive it directly — no SwiftUI hosting required. /// -/// `enqueue` schedules work on `@MainActor` via a child Task, so one `Task.yield()` +/// `post` schedules work on `@MainActor` via a child Task, so one `Task.yield()` /// after `await operation(...)` is needed to let that task run before asserting. @Suite("Task operations") @MainActor @@ -892,13 +1071,13 @@ struct TaskOperationTests { @Test func fetchSuccessSendsLoadedEvent() async { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case .task(_, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect else { Issue.record("Expected .task"); return } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } - await operation(input, LoaderEnv(fetch: { ["X", "Y"] })) + let input = TaskInput { [spy] event in spy.received.append(event) } + try? await operation(input, LoaderEnv(fetch: { ["X", "Y"] })) await Task.yield() #expect(spy.received == [.loaded(["X", "Y"])]) @@ -907,13 +1086,13 @@ struct TaskOperationTests { @Test func fetchFailureSendsFailedEvent() async { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case .task(_, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect else { Issue.record("Expected .task"); return } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } - await operation(input, LoaderEnv(fetch: { throw LoadFetchError(message: "timed out") })) + let input = TaskInput { [spy] event in spy.received.append(event) } + try? await operation(input, LoaderEnv(fetch: { throw LoadFetchError(message: "timed out") })) await Task.yield() #expect(spy.received == [.failed("timed out")]) @@ -922,13 +1101,13 @@ struct TaskOperationTests { @Test func tickerTaskEnqueuesTickedEvent() async { var state = CounterState() let effect = counterUpdate(state: &state, event: .start) - guard case .task(_, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect else { Issue.record("Expected .task"); return } let spy = EventSpy() - let input = Input { [spy] event, _, _ in spy.received.append(event) } - await operation(input, ()) + let input = TaskInput { [spy] event in spy.received.append(event) } + try? await operation(input, ()) await Task.yield() #expect(spy.received == [.ticked]) diff --git a/Tests/EffectViewTests/RunFailureLifecycleTests.swift b/Tests/EffectViewTests/RunFailureLifecycleTests.swift new file mode 100644 index 0000000..dd747f8 --- /dev/null +++ b/Tests/EffectViewTests/RunFailureLifecycleTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +import EffectView + +@Suite("Run stub") +struct RunFailureLifecycleTests { + + private final class TestStorage: Storage { + init(value: Value) { + self.value = value + } + + var value: Value + } + + private struct StubInput: TransducerInput, Sendable { + func post(_ event: sending Event) throws {} + + func request(_ event: Event) async throws -> Output? { + nil + } + } + + @MainActor + @Test func mainActorRunStubThrowsNotImplemented() async throws { + enum T: Transducer { + struct State: Equatable, Sendable { var count = 0 } + enum Event: Sendable { case increment } + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .increment: + state.count += 1 + return nil + } + } + } + + let input = StubInput() + let send = T.makeSend( + with: StubInput.self, + storage: TestStorage(value: T.State()), + env: () + ) + + do { + _ = try await T.run(send: send, initialState: T.State(), input: input) + Issue.record("Expected RunError.notImplemented") + } catch let error as RunError { + #expect(error == .notImplemented) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + @TestGlobalActor + func globalActorRunStubThrowsNotImplemented() async throws { + enum T: Transducer { + struct State: Equatable, Sendable { var count = 0 } + enum Event: Sendable { case increment } + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .increment: + state.count += 1 + return nil + } + } + } + + let input = StubInput() + let send = T.makeSend( + systemActor: TestGlobalActor.shared, + with: StubInput.self, + storage: TestStorage(value: T.State()), + env: () + ) + + do { + _ = try await T.run(send: send, initialState: T.State(), input: input) + Issue.record("Expected RunError.notImplemented") + } catch let error as RunError { + #expect(error == .notImplemented) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Tests/EffectViewTests/RuntimeUnavailableTests.swift b/Tests/EffectViewTests/RuntimeUnavailableTests.swift new file mode 100644 index 0000000..54a0158 --- /dev/null +++ b/Tests/EffectViewTests/RuntimeUnavailableTests.swift @@ -0,0 +1,276 @@ +import Testing +@testable import EffectView + +#if canImport(Observation) + +@Suite("Runtime unavailable") +@MainActor +struct RuntimeUnavailableTests { + + enum LatchedSystemError: Error, Equatable { + case boom + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case ping } + + static func update(_ state: inout State, event: Event) -> Effect? { + nil + } + } + + enum RequestCancellationTransducer: Transducer { + struct State: Equatable {} + enum Event: Sendable { case start } + struct Env: Sendable { + let started: Expectation + let cancelled: Expectation + } + + typealias Output = String + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + return task(id: "work") { _, env in + env.started.fulfill() + do { + while true { + try await Task.sleep(nanoseconds: 50_000_000) + } + } catch is CancellationError { + env.cancelled.fulfill() + throw CancellationError() + } + } + } + } + + static func output(state: State, event: Event) -> String { + "" + } + } + + enum LatchedFailureTransducer: Transducer { + struct State: Equatable {} + enum Event: Sendable { case start } + + typealias Output = String + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + return task(id: "work") { _, _ in + throw LatchedSystemError.boom + } + } + } + + static func output(state: State, event: Event) -> String { + "" + } + } + + @Test func observableSendThrowsRuntimeUnavailableWhenCancelled() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let observable = EffectObservable(initialState: .init(), env: ()) + observable.cancel() + + do { + try await observable.send(.ping) + Issue.record("Expected RuntimeUnavailable.actorCancelled") + } catch let error as RuntimeUnavailable { + #expect(error == .actorCancelled) + #expect(error.errorDescription == "The runtime is unavailable because it has already been cancelled.") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func observableRequestThrowsRuntimeUnavailableWhenCancelled() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let observable = EffectObservable(initialState: .init(), env: ()) + observable.cancel() + + do { + _ = try await observable.request(.ping) + Issue.record("Expected RuntimeUnavailable.actorCancelled") + } catch let error as RuntimeUnavailable { + #expect(error == .actorCancelled) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func inputSendThrowsRuntimeUnavailableWhenOwnerIsDeallocated() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) + let input = try #require(observable?.input) + observable = nil + + do { + try await input.send(.ping) + Issue.record("Expected RuntimeUnavailable.actorDeallocated") + } catch let error as RuntimeUnavailable { + #expect(error == .actorDeallocated) + #expect(error.errorDescription == "The runtime is unavailable because it has already been deallocated.") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func inputRequestThrowsRuntimeUnavailableWhenOwnerIsDeallocated() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) + let input = try #require(observable?.input) + observable = nil + + do { + _ = try await input.request(.ping) + Issue.record("Expected RuntimeUnavailable.actorDeallocated") + } catch let error as RuntimeUnavailable { + #expect(error == .actorDeallocated) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func inputPostThrowsRuntimeUnavailableWhenOwnerIsDeallocated() throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) + let input = try #require(observable?.input) + observable = nil + + do { + try input.post(.ping) + Issue.record("Expected RuntimeUnavailable.actorDeallocated") + } catch let error as RuntimeUnavailable { + #expect(error == .actorDeallocated) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func inputPostThrowsRuntimeUnavailableWhenRuntimeIsCancelled() throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let observable = EffectObservable(initialState: .init(), env: ()) + let input = observable.input + observable.cancel() + + do { + try input.post(.ping) + Issue.record("Expected RuntimeUnavailable.actorCancelled") + } catch let error as RuntimeUnavailable { + #expect(error == .actorCancelled) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func acceptedRequestIsCancelledWhenRuntimeIsCancelled() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + let observable = EffectObservable( + initialState: .init(), + env: .init(started: started, cancelled: cancelled) + ) + + let waiter = Task { + try await observable.request(.start) + } + + try await started.await(nanoseconds: timeout) + + observable.cancel() + + do { + _ = try await waiter.value + Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") + } catch let error as RuntimeUnavailable { + #expect(error == .actorCancelled) + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + try await cancelled.await(nanoseconds: timeout) + } + + @Test func requestThrowsLatchedSystemErrorInsteadOfHanging() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let observable = EffectObservable(initialState: .init(), env: ()) + + do { + _ = try await observable.request(.start) + Issue.record("Expected accepted request to receive the task failure") + } catch let error as LatchedSystemError { + #expect(error == .boom) + } catch { + Issue.record("Unexpected first request error: \(error)") + } + + do { + _ = try await observable.request(.start) + Issue.record("Expected later request to throw the latched system error") + } catch let error as RuntimeUnavailable { + #expect(error == .systemError) + } catch { + Issue.record("Unexpected later request error: \(error)") + } + } + + @Test func observableSendThrowsRuntimeUnavailableWhenRuntimeHasFailed() async throws { + guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { + return + } + + let observable = EffectObservable(initialState: .init(), env: ()) + + do { + _ = try await observable.request(.start) + Issue.record("Expected accepted request to receive the task failure") + } catch let error as LatchedSystemError { + #expect(error == .boom) + } catch { + Issue.record("Unexpected first request error: \(error)") + } + + do { + try await observable.send(.start) + Issue.record("Expected later send to throw RuntimeUnavailable.runtimeFailed") + } catch let error as RuntimeUnavailable { + #expect(error == .systemError) + #expect(error.errorDescription == "The runtime is unavailable because it has forcibly terminated because of a critical error.") + } catch { + Issue.record("Unexpected later send error: \(error)") + } + } +} + +#endif diff --git a/Tests/EffectViewTests/TaskManagerTests.swift b/Tests/EffectViewTests/TaskManagerTests.swift new file mode 100644 index 0000000..b2f5be9 --- /dev/null +++ b/Tests/EffectViewTests/TaskManagerTests.swift @@ -0,0 +1,299 @@ +import Foundation +import Testing +@testable import EffectView + +private enum TaskManagerFailure: Error, Equatable { + case boom + case later +} + +private enum TaskManagerStateSnapshot: Equatable, Sendable { + case active + case cancellingNoError + case cancellingBoom + case cancellingOther + case cancelledNoError + case cancelledBoom + case cancelledOther +} + +private actor TaskManagerHarness { + let taskManager = TaskManager() + + func request( + identifier: TaskIdentifier, + started: Expectation?, + cancelled: Expectation? + ) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + taskManager.addTask( + systemActor: self, + with: identifier, + option: .subscribe, + continuation: continuation, + isolatedOperation: { _ in + started?.fulfill() + do { + while true { + try await Task.sleep(nanoseconds: 50_000_000) + } + } catch is CancellationError { + cancelled?.fulfill() + throw CancellationError() + } + } + ) + } + } + + func cancelWithError(_ error: any Error) { + taskManager.cancel(with: error) + } + + func cancelWithoutError() { + taskManager.cancel() + } + + func failTrackedTask( + identifier: TaskIdentifier, + started: Expectation? + ) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + taskManager.addTask( + systemActor: self, + with: identifier, + option: .subscribe, + continuation: continuation, + isolatedOperation: { _ in + started?.fulfill() + throw TaskManagerFailure.boom + } + ) + } + } + + func checkCancellation() throws { + try taskManager.checkCancellation() + } + + func stateSnapshot() -> TaskManagerStateSnapshot { + switch taskManager.state { + case .active: + return .active + case .cancelling(let error): + switch error { + case nil: + return .cancellingNoError + case let error as TaskManagerFailure where error == .boom: + return .cancellingBoom + default: + return .cancellingOther + } + case .cancelled(let error): + switch error { + case nil: + return .cancelledNoError + case let error as TaskManagerFailure where error == .boom: + return .cancelledBoom + default: + return .cancelledOther + } + } + } +} + +@Suite("Task manager") +struct TaskManagerTests { + + @Test func cancelWithSystemErrorCancelsTrackedTasksAndRejectsNewAdds() async throws { + let harness = TaskManagerHarness() + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + + let waiter = Task { + try await harness.request( + identifier: "tracked", + started: started, + cancelled: cancelled + ) + } + + try await started.await(nanoseconds: timeout) + + await harness.cancelWithError(TaskManagerFailure.boom) + + do { + try await harness.checkCancellation() + Issue.record("Expected latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected latched error: \(error)") + } + + do { + _ = try await waiter.value + Issue.record("Expected active waiter to receive the latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + try await cancelled.await(nanoseconds: timeout) + + do { + _ = try await harness.request( + identifier: "tracked", + started: nil, + cancelled: nil + ) + Issue.record("Expected new waiter to be rejected after system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected rejected waiter error: \(error)") + } + } + + @Test func cancelWithSystemErrorKeepsFirstError() async throws { + let harness = TaskManagerHarness() + + await harness.cancelWithError(TaskManagerFailure.boom) + await harness.cancelWithError(TaskManagerFailure.later) + + do { + try await harness.checkCancellation() + Issue.record("Expected first latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func thrownTaskErrorLatchesSystemErrorAndCancelsItsWaiters() async throws { + let harness = TaskManagerHarness() + let started = Expectation() + let timeout: UInt64 = 5_000_000_000 + + let waiter = Task { + try await harness.failTrackedTask(identifier: "tracked", started: started) + } + + try await started.await(nanoseconds: timeout) + + do { + _ = try await waiter.value + Issue.record("Expected thrown task waiter to receive the latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + do { + try await harness.checkCancellation() + Issue.record("Expected latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected latched error: \(error)") + } + } + + @Test func cancelWithoutErrorCancelsActiveWaitersButRejectsFutureOnesAsRuntimeUnavailable() async throws { + let harness = TaskManagerHarness() + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + + let waiter = Task { + try await harness.request( + identifier: "tracked", + started: started, + cancelled: cancelled + ) + } + + try await started.await(nanoseconds: timeout) + + await harness.cancelWithoutError() + + do { + try await harness.checkCancellation() + Issue.record("Expected runtime unavailable cancellation") + } catch let error as RuntimeUnavailable { + #expect(error == .cancelled) + } catch { + Issue.record("Unexpected latched error: \(error)") + } + + do { + _ = try await waiter.value + Issue.record("Expected active waiter to receive CancellationError") + } catch is CancellationError { + /* expected */ + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + try await cancelled.await(nanoseconds: timeout) + + do { + _ = try await harness.request( + identifier: "tracked", + started: nil, + cancelled: nil + ) + Issue.record("Expected future waiter to be rejected as runtime unavailable") + } catch let error as RuntimeUnavailable { + #expect(error == .cancelled) + } catch { + Issue.record("Unexpected rejected waiter error: \(error)") + } + } + + @Test func cancelTransitionsToCancelledStateImmediatelyWhenNoTasksAreTracked() async { + let harness = TaskManagerHarness() + + await harness.cancelWithError(TaskManagerFailure.boom) + + #expect(await harness.stateSnapshot() == .cancelledBoom) + } + + @Test func cancelTransitionsFromCancellingToCancelledAfterTrackedTaskDrains() async throws { + let harness = TaskManagerHarness() + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + + let waiter = Task { + try await harness.request( + identifier: "tracked", + started: started, + cancelled: cancelled + ) + } + + try await started.await(nanoseconds: timeout) + + await harness.cancelWithError(TaskManagerFailure.boom) + + #expect(await harness.stateSnapshot() == .cancellingBoom) + + do { + _ = try await waiter.value + Issue.record("Expected active waiter to receive the latched system error") + } catch let error as TaskManagerFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected waiter error: \(error)") + } + + try await cancelled.await(nanoseconds: timeout) + + #expect(await harness.stateSnapshot() == .cancelledBoom) + } +} diff --git a/Tests/EffectViewTests/TaskSubscriptionTests.swift b/Tests/EffectViewTests/TaskSubscriptionTests.swift new file mode 100644 index 0000000..97f8c29 --- /dev/null +++ b/Tests/EffectViewTests/TaskSubscriptionTests.swift @@ -0,0 +1,723 @@ +#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) +import Foundation +import Testing +import SwiftUI +import EffectView + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + + +@Suite("Task subscription") +@MainActor +struct TaskSubscriptionTests { + + final actor InvocationCounter: Sendable { + private(set) var count = 0 + + init() {} + + @discardableResult + func increment() -> Int { + count += 1 + return count + } + } +} + +@MainActor +extension TaskSubscriptionTests { + + @Test func subscribeSharesNamedTaskResultBetweenWaiters() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let started: Expectation + let release: Expectation + let timeout: UInt64 + } + + final class RequestProbe: @unchecked Sendable { + let secondLoad: Expectation + + init(secondLoad: Expectation) { + self.secondLoad = secondLoad + } + } + + enum T: Transducer { + struct State: Equatable { + var loadCount = 0 + let probe: RequestProbe + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.loadCount == rhs.loadCount + } + } + enum Event: Sendable { case load } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + state.loadCount += 1 + if state.loadCount == 2 { + state.probe.secondLoad.fulfill() + } + return request(id: "shared-load", option: .subscribe) { _, env in + await env.counter.increment() + env.started.fulfill() + do { + try await env.release.await(nanoseconds: env.timeout) + } catch { + Issue.record(error, "Test Invariant failure: timeout. Increase the timeout value and run tests again.") + } + return "shared-output" + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let startedExpectation = Expectation() + let secondLoadExpectation = Expectation() + let releaseExpectation = Expectation() + let timeout: UInt64 = 10_000_000_000 + var capturedInput: EffectViewInput? + let probe = RequestProbe(secondLoad: secondLoadExpectation) + + + + try await testView(initialState: T.State(probe: probe)) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + started: startedExpectation, + release: releaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { + capturedInput = input + } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task.detached { try await input.request(.load) } + try await startedExpectation.await(nanoseconds: timeout) + + let secondWaiter = Task.detached { try await input.request(.load) } + try await secondLoadExpectation.await(nanoseconds: timeout) + releaseExpectation.fulfill() + + let firstOutput = try await firstWaiter.value + let secondOutput = try await secondWaiter.value + let count = await counter.count + + #expect(firstOutput == "shared-output") + #expect(secondOutput == "shared-output") + #expect(count == 1, "subscribe should share one underlying named task") + } + } + + @Test func subscribeSharedFailureLatchesSystemErrorAndCancelsWaiters() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let started: Expectation + let release: Expectation + let timeout: UInt64 + } + + enum SharedFailure: Error { + case boom + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return task(id: "shared-load", option: .subscribe) { _, env in + await env.counter.increment() + env.started.fulfill() + try? await env.release.await(nanoseconds: env.timeout) + throw SharedFailure.boom + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let startedExpectation = Expectation() + let releaseExpectation = Expectation() + let timeout: UInt64 = 50_000_000_000 + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + started: startedExpectation, + release: releaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task { try await input.request(.load) } + try await startedExpectation.await(nanoseconds: timeout) + + let secondWaiter = Task { try await input.request(.load) } + await Task.yield() + await Task.yield() + releaseExpectation.fulfill() + + do { + _ = try await firstWaiter.value + Issue.record("Expected first waiter to throw the shared task failure") + } catch let error as SharedFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected first waiter error: \(error)") + } + + do { + _ = try await secondWaiter.value + Issue.record("Expected second waiter to throw the shared task failure") + } catch let error as SharedFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected second waiter error: \(error)") + } + + let count = await counter.count + #expect(count == 1, "subscribe should share one underlying named task") } + } + + @Test func subscribeStartsFreshNamedTaskAfterPreviousOneCompletes() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return request(id: "shared-load", option: .subscribe) { _, env in + let count = await env.counter.increment() + return "output-\(count)" + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv(counter: counter) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstOutput = try await input.request(.load) + let secondOutput = try await input.request(.load) + + #expect(firstOutput == "output-1") + #expect(secondOutput == "output-2") + #expect(await counter.count == 2, "a later subscriber should start a fresh named task after completion") + } + } + + @Test func subscribeAttachesToCancelledTrackedTaskAndReceivesItsLateResult() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let started: Expectation + let cancelled: Expectation + let release: Expectation + let timeout: UInt64 + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load, stop } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return request(id: "shared-load", option: .subscribe) { _, env in + let invocation = await env.counter.increment() + if invocation > 1 { + return "fresh-output-\(invocation)" + } + env.started.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-output" + } catch is CancellationError { + env.cancelled.fulfill() + do { + try await env.release.await(nanoseconds: env.timeout) + } catch { + Issue.record(error, "Test invariant failure: timeout while waiting to release cancelled task") + } + return "late-output" + } catch { + return error.localizedDescription + } + } + case .stop: + return cancel("shared-load") + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let startedExpectation = Expectation() + let cancelledExpectation = Expectation() + let releaseExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + started: startedExpectation, + cancelled: cancelledExpectation, + release: releaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task { try await input.request(.load) } + try await startedExpectation.await(nanoseconds: timeout) + + try await input.send(.stop) + try await cancelledExpectation.await(nanoseconds: timeout) + + do { + _ = try await firstWaiter.value + Issue.record("Expected first waiter to throw CancellationError") + } catch is CancellationError { + /* expected */ + } catch { + Issue.record("Unexpected first waiter error: \(error)") + } + + let secondWaiter = Task { try await input.request(.load) } + await Task.yield() + releaseExpectation.fulfill() + + let secondOutput = try await secondWaiter.value + // TODO: Intermitently fails during test loop + #expect(secondOutput == "late-output") + #expect(await counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") + } + + } + + @Test func subscribeAttachesToCancelledTrackedTaskAndCancelsWaitersOnLateFailure() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let started: Expectation + let cancelled: Expectation + let release: Expectation + let timeout: UInt64 + } + + enum LateFailure: Error { + case boom + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load, stop } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return task(id: "shared-load", option: .subscribe) { _, env in + let invocation = await env.counter.increment() + if invocation > 1 { + return "fresh-output-\(invocation)" + } + env.started.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-output" + } catch is CancellationError { + env.cancelled.fulfill() + try? await env.release.await(nanoseconds: env.timeout) + throw LateFailure.boom + } + } + case .stop: + return cancel("shared-load") + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let startedExpectation = Expectation() + let cancelledExpectation = Expectation() + let releaseExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + started: startedExpectation, + cancelled: cancelledExpectation, + release: releaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task { try await input.request(.load) } + try await startedExpectation.await(nanoseconds: timeout) + + try await input.send(.stop) + try await cancelledExpectation.await(nanoseconds: timeout) + + do { + _ = try await firstWaiter.value + Issue.record("Expected first waiter to throw CancellationError") + } catch is CancellationError { + /* expected */ + } catch { + Issue.record("Unexpected first waiter error: \(error)") + } + + let secondWaiter = Task { try await input.request(.load) } + await Task.yield() + releaseExpectation.fulfill() + + do { + _ = try await secondWaiter.value + Issue.record("Expected second waiter to throw the late task failure") + } catch let error as LateFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected second waiter error: \(error)") + } + + #expect(await counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") + } + } + + @Test func switchToLatestRestartsTaskAndReturnsReplacementResultToAllWaiters() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let firstStarted: Expectation + let firstCancelled: Expectation + let secondStarted: Expectation + let secondRelease: Expectation + let timeout: UInt64 + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case first, second } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .first: + return task(id: "replaceable", option: .switchToLatest) { _, env in + let invocation = await env.counter.increment() + if invocation == 1 { + env.firstStarted.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-first-output" + } catch is CancellationError { + env.firstCancelled.fulfill() + throw CancellationError() + } + } + env.secondStarted.fulfill() + try? await env.secondRelease.await(nanoseconds: env.timeout) + return "replacement-output" + } + case .second: + return task(id: "replaceable", option: .switchToLatest) { _, env in + let invocation = await env.counter.increment() + if invocation == 1 { + env.firstStarted.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-first-output" + } catch is CancellationError { + env.firstCancelled.fulfill() + throw CancellationError() + } + } + env.secondStarted.fulfill() + try? await env.secondRelease.await(nanoseconds: env.timeout) + return "replacement-output" + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let firstStartedExpectation = Expectation() + let firstCancelledExpectation = Expectation() + let secondStartedExpectation = Expectation() + let secondReleaseExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + firstStarted: firstStartedExpectation, + firstCancelled: firstCancelledExpectation, + secondStarted: secondStartedExpectation, + secondRelease: secondReleaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task { try await input.request(.first) } + try await firstStartedExpectation.await(nanoseconds: timeout) + + let secondWaiter = Task { try await input.request(.second) } + try await firstCancelledExpectation.await(nanoseconds: timeout) + try await secondStartedExpectation.await(nanoseconds: timeout) + secondReleaseExpectation.fulfill() + + let firstOutput = try await firstWaiter.value + let secondOutput = try await secondWaiter.value + #expect(firstOutput == "replacement-output") + #expect(secondOutput == "replacement-output") + #expect(await counter.count == 2, "switchToLatest should restart the active task") + } + + } + + @Test func switchToLatestReplacementFailureCancelsAllWaiters() async throws { + struct WorkerEnv: Sendable { + let counter: InvocationCounter + let firstStarted: Expectation + let firstCancelled: Expectation + let secondStarted: Expectation + let secondRelease: Expectation + let timeout: UInt64 + } + + enum ReplacementFailure: Error { + case boom + } + + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case first, second } + typealias Output = String + typealias Env = WorkerEnv + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .first: + return task(id: "replaceable", option: .switchToLatest) { _, env in + let invocation = await env.counter.increment() + if invocation == 1 { + env.firstStarted.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-first-output" + } catch is CancellationError { + env.firstCancelled.fulfill() + throw CancellationError() + } + } + env.secondStarted.fulfill() + try? await env.secondRelease.await(nanoseconds: env.timeout) + throw ReplacementFailure.boom + } + case .second: + return task(id: "replaceable", option: .switchToLatest) { _, env in + let invocation = await env.counter.increment() + if invocation == 1 { + env.firstStarted.fulfill() + do { + try await Task.sleep(nanoseconds: env.timeout) + return "stale-first-output" + } catch is CancellationError { + env.firstCancelled.fulfill() + throw CancellationError() + } + } + env.secondStarted.fulfill() + try? await env.secondRelease.await(nanoseconds: env.timeout) + throw ReplacementFailure.boom + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + let counter = InvocationCounter() + let firstStartedExpectation = Expectation() + let firstCancelledExpectation = Expectation() + let secondStartedExpectation = Expectation() + let secondReleaseExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView( + of: T.self, + state: binding, + initialEnv: WorkerEnv( + counter: counter, + firstStarted: firstStartedExpectation, + firstCancelled: firstCancelledExpectation, + secondStarted: secondStartedExpectation, + secondRelease: secondReleaseExpectation, + timeout: timeout + ) + ) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let firstWaiter = Task { try await input.request(.first) } + try await firstStartedExpectation.await(nanoseconds: timeout) + + let secondWaiter = Task { try await input.request(.second) } + try await firstCancelledExpectation.await(nanoseconds: timeout) + try await secondStartedExpectation.await(nanoseconds: timeout) + secondReleaseExpectation.fulfill() + + do { + _ = try await firstWaiter.value + Issue.record("Expected first waiter to throw the replacement task failure") + } catch let error as ReplacementFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected first waiter error: \(error)") + } + + do { + _ = try await secondWaiter.value + Issue.record("Expected second waiter to throw the replacement task failure") + } catch let error as ReplacementFailure { + #expect(error == .boom) + } catch { + Issue.record("Unexpected second waiter error: \(error)") + } + + #expect(await counter.count == 2, "switchToLatest should restart the active task") + } + } + + @Test("anonymous request task completes as an unshared task") + func anonymousRequestTaskCompletesAsUnsharedTask() async throws { + enum T: Transducer { + struct State: Equatable {} + enum Event: Sendable { case load } + typealias Output = String + + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .load: + return request(id: nil, option: .subscribe) { _, _ in + "anonymous-output" + } + } + } + + static func output(state: State, event: Event) -> String { "" } + } + + var capturedInput: EffectViewInput? + + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { _, input in + Color.clear.onAppear { capturedInput = input } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + + let output = try await input.request(.load) + #expect(output == "anonymous-output") + } + } +} + +#else +import Testing + +@Suite("Task subscription (SwiftUI unavailable)") +struct TaskSubscriptionTests { + @Test func skipped() {} +} + +#endif diff --git a/Tests/EffectViewTests/Utilities/TestView.swift b/Tests/EffectViewTests/Utilities/TestView.swift new file mode 100644 index 0000000..e70c563 --- /dev/null +++ b/Tests/EffectViewTests/Utilities/TestView.swift @@ -0,0 +1,134 @@ +#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) +import Foundation +import Testing +import SwiftUI +@testable import EffectView +#if canImport(Observation) +import Observation +#endif + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@MainActor +struct TestView: View { + + @SwiftUI.State private var storage: State + private let content: (Binding) -> Content + + init(initialState: State, @ViewBuilder content: @escaping (Binding) -> Content) { + self._storage = .init(wrappedValue: initialState) + self.content = content + } + + var body: some View { + content($storage) + } +} + +#if canImport(UIKit) + typealias HostingController = UIHostingController + typealias PlatformWindow = UIWindow +#elseif canImport(AppKit) + typealias HostingController = NSHostingController + typealias PlatformWindow = NSWindow +#endif + +struct EmbedInWindowAndMakeKeyTimeoutError: Error {} + + +@MainActor +func embedInWindowAndMakeKey(_ view: V, timeout: TimeInterval = 1.0) async throws -> (HostingController, PlatformWindow) { + var hostingController: HostingController? + var window: PlatformWindow? + var isResumed = false + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let timeoutCancelTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(timeout * 100_000_000 + 0.5)) + guard isResumed == false else { return } + isResumed = true + continuation.resume(throwing: EmbedInWindowAndMakeKeyTimeoutError()) + } + + hostingController = HostingController( + rootView: AnyView( + view.onAppear { + DispatchQueue.main.async { + guard isResumed == false else { return } + timeoutCancelTask.cancel() + isResumed = true + continuation.resume() + } + } + ) + ) + +#if canImport(UIKit) + window = UIWindow() + window!.rootViewController = hostingController + window!.makeKeyAndVisible() +#elseif canImport(AppKit) + window = NSWindow(contentViewController: hostingController!) + window!.makeKeyAndOrderFront(nil) +#endif + } + + return (hostingController!, window!) +} + +@MainActor +func cleanup(_ window: PlatformWindow?) { +#if canImport(UIKit) + window?.isHidden = true +#elseif canImport(AppKit) + window?.close() + window?.orderOut(nil) +#endif +} + +@MainActor +func testView( + initialState: State, + @ViewBuilder content: @escaping @MainActor (Binding) -> Content, + expect: @escaping @MainActor () async throws -> () +) async throws { + CATransaction.begin() + + let testView = TestView(initialState: initialState) { binding in + content(binding) + } + let (_, window) = try await embedInWindowAndMakeKey(testView) + + do { + try await expect() + await teardown() + } catch { + await teardown() + throw error + } + + func teardown() async { + window.close() + CATransaction.commit() + CATransaction.flush() + + // Drain the AppKit Window Server queue completely before exiting the test frame + // This stops the next loop iteration from colliding with trailing CA commits. + await withCheckedContinuation { (continuation: CheckedContinuation) in + for _ in 0..<10 { + let date = Date(timeIntervalSinceNow: 0.005) + RunLoop.current.run(until: date) + } + continuation.resume() + } + + // Final yield to clear async MainActor scheduling overhead + await Task.yield() + } +} + +#endif