diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a338218..34eb59e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.27.0", + "version": "2.28.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6b16df7..c66db62 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -115,7 +115,7 @@ Before hand-writing a helper, check whether it already exists — the catalog in - Credential rejection / typed 401 recovery → `FOSMVVM.md § Protocols` - Form fields and input validation → `FOSMVVM.md § Forms`, `§ Validation` - SwiftUI binding/app setup, property versioning, deployment URLs → `FOSMVVM.md § SwiftUI Support`, `§ Versioning` -- Async Button actions (error routing, re-entry, cancel), localized error alerts → `FOSMVVM.md § SwiftUI Support`, `§ Protocols` +- Async Button actions (error routing, re-entry, cancel), view-lifetime `.task` error routing, localized error alerts → `FOSMVVM.md § SwiftUI Support`, `§ Protocols` - Vapor boot/Leaf, routes, Fluent factories, versioned middleware → `FOSMVVMVapor.md § Extensions`, `§ Vapor Support`, `§ Protocols`, `§ Middleware` - Live ViewModel refresh (server push), incl. nudging live clients from non-Fluent/hybrid sources → `FOSMVVMVapor.md § Live Invalidation` - Testing ViewModels / UI / ServerRequests → `FOSTesting.md § FOSTesting`, `§ FOSTestingUI`, `§ FOSTestingVapor` diff --git a/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md b/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md index 70c9fc6..b78d99e 100644 --- a/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md +++ b/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md @@ -198,7 +198,7 @@ public struct MyView: ViewModelView { **Why `toggleRepaint()` exists.** Client-hosted ops mutate `@Observable` storage that test harnesses (and occasionally SwiftUI itself) don't always re-observe in time for the next assertion. Toggling a `@State` flag forces a deterministic re-render at the View boundary, so UI tests see the post-op state instead of the pre-op state. In production builds the toggle is compiled out — it costs nothing at runtime. -**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with the async `Button` forms — `Button(error:action:)` and its `Localizable`-titled twins — which deposit a thrown error into the screen's `error:` binding (add `activity:` for re-entry refusal, `cancelTitle:` for tap-to-cancel; see the FOSMVVM DocC article *Async Actions and Error Presentation*). For view-lifetime loads, catch into the binding by hand inside `.task { }` — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped. Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`. +**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with the async `Button` forms — `Button(error:action:)` and its `Localizable`-titled twins — which deposit a thrown error into the screen's `error:` binding (add `activity:` for re-entry refusal, `cancelTitle:` for tap-to-cancel; see the FOSMVVM DocC article *Async Actions and Error Presentation*). For view-lifetime loads, use the `.task(error:)` twins — `.task(error: $error) { try await loadData() }`, or `.task(id:error:)` to restart the load when a value changes; a thrown error lands in the same binding, and cancellation (teardown, an `id` restart, or the `CancellationError` sentinel) never deposits into it (see the FOSMVVM DocC article *Async Action Lifecycle and Cancellation*). Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`. The example above shows a **server-backed** op — `operations.performAction()` dispatches a `ServerRequest`. For **client-hosted** ops (those that mutate local `@Observable` storage), the call site shape is different — the View must inject storage from the environment and hand it to the op explicitly. See below. @@ -632,7 +632,7 @@ Button(viewModel.submitLabel, error: $error) { ### Async Task Pattern -For view-lifetime loads, catch into the binding by hand (an error-routing `.task` twin is queued in FOSUtilities but not yet shipped): +For view-lifetime loads, `.task(error:)` routes a thrown error into the screen's binding; cancellation — view teardown, an `id:` restart, or the `CancellationError` sentinel — never deposits into it: ```swift var body: some View { @@ -643,8 +643,8 @@ var body: some View { contentView } } - .task { - do { try await loadData() } catch { self.error = error } + .task(error: $error) { + try await loadData() } } @@ -656,6 +656,8 @@ private func loadData() async throws { } ``` +To restart the load when a value changes, key it: `.task(id: viewModel.selectedId, error: $error) { ... }` — the superseded invocation writes nothing to the binding. The lifecycle semantics are drawn situation-by-situation in the FOSMVVM DocC article *Async Action Lifecycle and Cancellation*. + ### Conditional Rendering Pattern Use ViewModel state for conditionals: @@ -996,10 +998,9 @@ See [reference.md](reference.md) for complete file templates. dismissButtonLabel: viewModel.dismissButtonLabel ) -// Async task — catch into the binding by hand (an error-routing -// .task twin is queued in FOSUtilities but not yet shipped) -.task { - do { try await loadData() } catch { self.error = error } +// Async task — errors route into the same binding; cancellation never deposits +.task(error: $error) { + try await loadData() } // Keyboard submit routing into the same binding diff --git a/.claude/skills/fosmvvm-swiftui-view-generator/reference.md b/.claude/skills/fosmvvm-swiftui-view-generator/reference.md index b93fc94..db00620 100644 --- a/.claude/skills/fosmvvm-swiftui-view-generator/reference.md +++ b/.claude/skills/fosmvvm-swiftui-view-generator/reference.md @@ -509,8 +509,8 @@ public struct {ViewName}View: ViewModelView { } .padding() } - .task { - do { try await loadItems() } catch { self.error = error } + .task(error: $error) { + try await loadItems() } .alert( error: $error, @@ -854,7 +854,7 @@ public struct {ViewName}View: ViewModelView { } .padding() } - .task { do { try await loadItems() } catch { self.error = error } } + .task(error: $error) { try await loadItems() } .alert( error: $error, title: viewModel.errorTitle, @@ -993,11 +993,11 @@ Add `activity: $activity` (an `@State AsyncButtonActivity`) for re-entry refusal ### Task on Appear -Catch into the binding by hand — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped: +`.task(error:)` routes a thrown error into the screen's binding; cancellation — view teardown, an `id:` restart, or the `CancellationError` sentinel — never deposits into it: ```swift -.task { - do { try await loadData() } catch { self.error = error } +.task(error: $error) { + try await loadData() } private func loadData() async throws { @@ -1006,6 +1006,8 @@ private func loadData() async throws { } ``` +To restart the load when a value changes: `.task(id: viewModel.selectedId, error: $error) { ... }` — the superseded invocation writes nothing. See the FOSMVVM DocC article *Async Action Lifecycle and Cancellation*. + ### Conditional Rendering ```swift diff --git a/.claude/skills/fosmvvm-ui-tests-generator/reference.md b/.claude/skills/fosmvvm-ui-tests-generator/reference.md index 18117ba..fb6606a 100644 --- a/.claude/skills/fosmvvm-ui-tests-generator/reference.md +++ b/.claude/skills/fosmvvm-ui-tests-generator/reference.md @@ -561,8 +561,8 @@ public struct {ViewName}View: ViewModelView { contentView } } - .task { - do { try await loadData() } catch { self.error = error } + .task(error: $error) { + try await loadData() } .alert( error: $error, @@ -699,8 +699,8 @@ public struct {ViewName}View: ViewModelView { .disabled(selectedId == nil) } } - .task { - do { try await loadItems() } catch { self.error = error } + .task(error: $error) { + try await loadItems() } .alert( error: $error, diff --git a/.claude/skills/fosutilities-api-catalog/SKILL.md b/.claude/skills/fosutilities-api-catalog/SKILL.md index bf9fe07..941ddd1 100644 --- a/.claude/skills/fosutilities-api-catalog/SKILL.md +++ b/.claude/skills/fosutilities-api-catalog/SKILL.md @@ -55,6 +55,7 @@ line via the `fosutilities-api-catalog-update` skill. - Declaring the data a server-rendered body needs — composable factory, load requirements, rooted scopes → `FOSMVVM.md § Protocols` - Rendering a ViewModel in SwiftUI — app setup, view binding, previews, form views → `FOSMVVM.md § SwiftUI Support` - A Button whose action is `async throws` — error routing, re-entry refusal, running state, tap-to-cancel → `FOSMVVM.md § SwiftUI Support` +- A view-lifetime (or value-keyed) load that can throw — `.task`-style error routing into the screen binding → `FOSMVVM.md § SwiftUI Support` - Showing errors to the user — localized error messages on error types, one alert fed by a shared error binding → `FOSMVVM.md § Protocols`, `§ SwiftUI Support` - Versioning ViewModel properties, choosing deployment URLs, negotiating versions over HTTP → `FOSMVVM.md § Versioning` - Booting a Vapor server for MVVM — YAML localization store, environment, locale, Leaf rendering → `FOSMVVMVapor.md § Extensions` diff --git a/.claude/skills/shared/api-catalog/FOSMVVM.md b/.claude/skills/shared/api-catalog/FOSMVVM.md index 2e786b7..a4007a4 100644 --- a/.claude/skills/shared/api-catalog/FOSMVVM.md +++ b/.claude/skills/shared/api-catalog/FOSMVVM.md @@ -375,7 +375,7 @@ caller (never `requestErrorHandler`). } ``` -### Give an error a user-presentable localized message — `LocalizableError` / `ClientHostedLocalizableError` / `localized()` +### Give an error a user-presentable localized message — `LocalizableError` / `ClientHostedLocalizableError` / `LocalizableErrorOptions` / `localized()` Reach for this when: an error will be shown to the user — canonically a `ServerRequestError` conformer — and its message belongs in your YAML, not in code. Compose it exactly like a ViewModel: `@Localized…` message property, @@ -949,11 +949,40 @@ to cancel (cooperatively): pass `cancelTitle:` (optionally label closure on the ViewBuilder forms; the activity binding is then required, and `activity.cancel()` also cancels from outside the button (e.g. `.onDisappear`). Share one activity across buttons to make them mutually -exclusive. Pair the `error:` binding with the error alert in the next entry. +exclusive. Cancellation never reaches the binding: a cancelled invocation +deposits nothing, and the `CancellationError` sentinel type is filtered even +from a non-cancelled invocation (discards are debug-logged) — the DocC article +*Async Action Lifecycle and Cancellation* draws every situation. Pair the +`error:` binding with the error alert in the *Present errors from the shared +binding* entry below. + +### Route a view-lifetime load's error to the screen binding — `task` +Reach for this when: the screen's load runs for the view's lifetime — +`task(error:)` starts it on appearance, and `task(id:error:)` restarts it when +a value changes; a thrown error lands in the same `error:` binding your async +buttons feed. +Don't catch into the binding by hand inside `.task { do/catch }` — teardown +and superseded restarts then deposit `CancellationError` into the alert. + +```swift +@State private var error: Error? + +DocumentDetail(viewModel: viewModel) + .task(id: viewModel.selectedDocumentId, error: $error) { + try await viewModel.operations.loadDocument() + } +``` + +Each start clears the binding — it always holds the most recent invocation's +outcome. Cancellation never deposits: view teardown and `id`-change restarts +write nothing (a superseded load cannot speak over the current one), and the +`CancellationError` sentinel is filtered even from a non-cancelled invocation +(discards are debug-logged). The DocC article *Async Action Lifecycle and +Cancellation* draws every situation. ### Present errors from the shared binding — `alert()` -Reach for this when: showing the errors your async buttons (or any hand-written -catch) deposit in the screen's `@State var error: Error?` — one modifier is the +Reach for this when: showing the errors your async buttons and `task(error:)` +loads deposit in the screen's `@State var error: Error?` — one modifier is the screen's single presentation point. Shows while non-`nil`, clears on dismissal, localizes `LocalizableError` conformers through the client store (others show their debug description), and fills the message's `%{error}` substitution point diff --git a/CHANGELOG.md b/CHANGELOG.md index 79aea94..c8c0428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.13.0] - 2026-08-20 +### Added + +- **`task(error:)` / `task(id:error:)`** (FOSMVVM) — async twins of SwiftUI's `task` + modifiers for view-lifetime loads: the action becomes throwing, and a thrown error lands + in the required `error: Binding` (cleared on each start — the binding holds the + outcome of the most recent invocation). Cancellation never reaches the binding: view + teardown and `id`-change restarts deposit nothing, so a superseded load can never speak + over the current one and teardown never puts a `CancellationError` in an alert. Pairs + with `alert(error:)` and the async Button forms on one screen-level binding. The new + *Async Action Lifecycle and Cancellation* DocC article draws the full contract, + situation by situation. + +### Changed + +- **Async Button cancellation never reaches `error`** (FOSMVVM) — the engine's deposit + guard now also filters the language's `CancellationError` sentinel type: a + `CancellationError` thrown by a *non-cancelled* invocation is discarded instead of + presented, making the quiet exit (throw `CancellationError` to end with nothing shown) + a supported idiom. The filter is sentinel-only — errors that describe a cancellation in + domain vocabulary still deposit and present. Every discarded outcome is recorded with a + debug notice. The full lifecycle contract is drawn situation-by-situation in the new + *Async Action Lifecycle and Cancellation* DocC article. ### Added diff --git a/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md b/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md index 220d85b..cb9dcaf 100644 --- a/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md +++ b/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md @@ -6,12 +6,15 @@ Run a throwing async operation from a Button, route its failure to one screen-le Most user-initiated actions in an FOSMVVM application are asynchronous and can fail: the View dispatches through ``ViewModelOperations`` (see ), the operation performs a ``ServerRequest``, and the server may answer with a typed error. -Three pieces carry that flow from tap to alert, and they are designed to be wired together: +Four pieces carry that flow — from tap or view appearance to alert — and they are designed to be wired together: - **Async `Button` forms** run a `@Sendable () async throws` action and deposit any thrown error into an `error:` binding. +- **`task(error:)` / `task(id:error:)`** run a view-lifetime (or value-keyed) load and route its error into the same binding. - **`alert(error:title:message:dismissButtonLabel:)`** presents whatever lands in that binding, localized. - **``LocalizableError``** gives your error types user-presentable, YAML-localized messages — composed exactly like a ``ViewModel``. +What the binding holds at every moment — success, failure, cancellation, restarts — is drawn situation-by-situation in . + ## The Basic Wiring One `@State` error per screen, fed by every async button on it, presented by one alert: @@ -84,6 +87,18 @@ While running, the button shows the cancel face and a tap cancels the operation; For long-running *server* work, model the operation as a server-tracked resource and cancel it with another request — client-side cancellation only abandons the response. +## View-Lifetime Loads + +The screen's initial load is not a tap — it belongs to the view's lifetime. The `task(error:)` twins of SwiftUI's `task` modifiers run a throwing load and feed the same binding: + +```swift +.task(id: viewModel.selectedDocumentId, error: $error) { + try await viewModel.operations.loadDocument() +} +``` + +The load starts on appearance — and restarts when `id` changes; a thrown error lands in `error` and the alert presents it. Cancellation — leaving the screen, an `id` restart, or the `CancellationError` sentinel — never deposits into the binding, so teardown and superseded loads stay silent. The full contract is in . + ## Localizing Your Errors Conform an error to ``LocalizableError`` and the alert presents it in the user's language; errors that do not conform are presented with their debug description. Compose the conformer exactly like a ``ViewModel`` — a `@LocalizedString` or `@LocalizedSubs` message property, plumbing from the `@LocalizableError` macro: diff --git a/Sources/FOSMVVM/FOSMVVM.docc/AsyncLifecycle.md b/Sources/FOSMVVM/FOSMVVM.docc/AsyncLifecycle.md new file mode 100644 index 0000000..70ac683 --- /dev/null +++ b/Sources/FOSMVVM/FOSMVVM.docc/AsyncLifecycle.md @@ -0,0 +1,311 @@ +# Async Action Lifecycle and Cancellation + +Watch what the `error` binding and ``AsyncButtonActivity/phase`` hold at each moment of an async action's life — nine situations, traced one at a time against a single screen. + +## Overview + +Every async surface in FOSMVVM — the async `Button` forms and the error-routing `task(error:)` modifiers — reports its outcome through a screen-level `error` binding. Buttons that take an ``AsyncButtonActivity`` also report where their work is in its lifecycle, through ``AsyncButtonActivity/phase``. + +This article declares one screen and then walks it through nine situations: what the user does, what runs, what each binding holds, and what appears on screen. Each walkthrough ends with the one-sentence rule it just demonstrated, and the final section collects those sentences into the full contract. + +For the wiring itself — which forms exist and how to declare them — see . + +## One Screen, Every Situation + +Here is the screen every walkthrough uses — a document view whose content loads for the lifetime of the view, plus two buttons: + +```swift +struct DocumentView: ViewModelView { + let viewModel: DocumentViewModel + + @State private var error: Error? + @State private var activity = AsyncButtonActivity() + + var body: some View { + VStack { + // ... the document content ... + + Button(viewModel.saveTitle, activity: $activity, error: $error) { + try await viewModel.operations.save() + } + + Button(viewModel.uploadTitle, cancelTitle: viewModel.cancelTitle, + activity: $activity, error: $error) { + try await viewModel.operations.upload() + } + } + .task(id: viewModel.selectedDocumentId, error: $error) { + try await viewModel.operations.loadDocument() + } + .alert(error: $error, + title: viewModel.errorTitle, + dismissButtonLabel: viewModel.dismissTitle) + } +} +``` + +- The **save** button has no cancellation support — it appears in the saving, retrying, and tapping-while-running walkthroughs. +- The **upload** button's `cancelTitle:` gives it a cancellation support — it appears in the cancellation walkthroughs. +- The **`task(id:error:)`** load appears in the navigating-away and switching-documents walkthroughs. +- All three deposit into the same `error` binding, presented by the one alert, and the two buttons share one `activity`. + +The localization YAML and the rest of the wiring for a screen like this are shown in . + +## Who Writes `error`, and When + +`error` is your `@State`, and it has exactly three writers: + +1. **The async surface** (a button or the `task` modifier) writes at most twice per invocation: `nil` at the moment the invocation starts, and the thrown error at the moment the invocation fails. +2. **The alert** writes `nil` when the user dismisses it. +3. **Your own code** can write it, since you own the state — the walkthroughs assume you don't. + +`activity.phase` has one writer — the framework — and it only ever moves the value along one path: `idle` to `running` when an invocation starts, `running` to `cancelling` when cancellation is requested, and back to `idle` when the work has unwound. + +Everything in the rest of this article is these writers acting at their moments. When a walkthrough surprises you, come back to this list and find which writer acted — or held back. + +## Saving Succeeds + +You tap Save. The button writes `nil` into `error`, moves `activity.phase` to `running`, and starts the action. `try await viewModel.operations.save()` runs and returns. The button writes nothing more to `error`, and the phase returns to `idle`. `error` is `nil`, and no alert appears. + +``` +User Button activity.phase error + │ │ │ idle │ nil + │ tap │ │ │ + │────────────▶│ clear │ │ + │ │───────────────┼────────────────▶│ nil + │ │──────────────▶│ running │ + │ │ run action │ │ + │ │ …succeeds… │ │ + │ │ (writes │ │ + │ │ nothing) │ │ + │ │──────────────▶│ idle │ nil — no alert +``` + +The document's saved state reaches the screen the way all state does in FOSMVVM — through the ViewModel — so the error channel had nothing to say here. + +**The rule this trace shows:** a successful invocation leaves `error` at `nil`. + +## Saving Fails + +You tap Save. The button writes `nil` into `error`, moves `activity.phase` to `running`, and starts the action. This time `save()` throws. The button writes the thrown error into `error`, and the phase returns to `idle` — failure ends an invocation the same way success does. The alert — which presents whenever `error` is non-`nil` — appears with the localized message. You tap OK; the alert writes `nil`; the screen is back where it started. + +``` +User Button activity.phase error + │ tap │ │ idle │ + │────────────▶│ clear │ │ + │ │───────────────┼────────────────▶│ nil + │ │──────────────▶│ running │ + │ │ run action │ │ + │ │ …throws e… │ │ + │ │ deposit │ │ + │ │───────────────┼────────────────▶│ e — alert + │ │──────────────▶│ idle │ presents + │ dismiss │ │ │ + │─────────────┼───────────────┼────────────────▶│ nil +``` + +Notice that the upload button and the `task` load deposit into this same binding — whichever surface fails, this same alert presents it. One binding and one alert per screen is the intended shape. + +**The rule this trace shows:** a thrown error lands in `error`, and dismissing the alert clears it. + +## Retrying After a Failure + +Suppose this screen presented its errors inline — a text row reading from `error` — instead of an alert, so a failure message can still be on screen when you tap Save again. + +The failure `e₁` sits in `error`; the row shows its message. You tap Save. The button's first write is `nil` — the stale message leaves the screen at the tap, before the new attempt has done any work. The retry then fails with `e₂`, and `e₂` is deposited. `activity.phase` makes its usual round trip, `idle` to `running` and back. + +``` +User Button activity.phase error + │ │ │ idle │ e₁ (previous failure) + │ tap (retry)│ │ │ + │────────────▶│ clear │ │ + │ │───────────────┼────────────────▶│ nil + │ │──────────────▶│ running │ + │ │ run action │ │ + │ │ …throws e₂… │ │ + │ │───────────────┼────────────────▶│ e₂ + │ │──────────────▶│ idle │ +``` + +At no point could the screen show `e₁` next to the retry's outcome — the binding held one invocation's outcome at a time. This also means `error` is not a history; if your screen needs earlier failures preserved, copy them into your own state before retrying. + +**The rule this trace shows:** starting an invocation clears `error` first, so the binding always holds the outcome of the most recent invocation. + +## Cancelling the Upload + +You tap Upload. The button writes `nil` into `error`, starts the action, and moves `activity.phase` to `running` — and because `running` is the phase the upload button renders its `cancelTitle:` face from, the button now reads Cancel. + +You tap Cancel. The phase moves to `cancelling`, and cancellation is requested of the running work. The work unwinds — usually by throwing `CancellationError`, sometimes with a genuine failure that raced your cancel. Either way, the surface's second writer holds back: the invocation was cancelled, so nothing is deposited — a failure you cancelled into is one you'll meet again if you retry, and the discarded outcome is recorded in the debug log. When the unwind completes, the phase returns to `idle` and the button reads Upload again. `error` was `nil` throughout; no alert appeared. + +``` +User Button activity.phase error + │ tap │ │ idle │ nil + │────────────▶│ start │ │ + │ │──────────────▶│ running │ + │ tap (✕) │ │ │ + │────────────▶│ cancel │ │ + │ │──────────────▶│ cancelling │ + │ │ …unwinds… │ │ + │ │──────────────▶│ idle │ nil — nothing + │ │ │ │ written +``` + +If the screen should confirm the cancellation, the phase is where that lives. `cancelling` means "stopping was requested"; the return to `idle` means "the work has stopped." The screen can watch that transition: + +```swift +.onChange(of: activity.phase) { previous, current in + if previous == .cancelling, current == .idle { + showCancelledConfirmation = true + } +} +``` + +The two bindings divide the work: `error` answers "did the outcome I wanted happen?", and `activity.phase` answers "where is the work right now?". A cancellation is the second kind of fact. + +> Important: Cancellation is cooperative — the action must run cancellation-aware work (any `URLSession`-backed ``ServerRequest`` is) for the request to take effect. + +**The rule this trace shows:** a cancelled invocation writes nothing to `error`; its story is told by `activity.phase`. + +## Tapping Save While It Runs + +You tap Save; the phase moves to `running`. You tap Save again before it finishes. Nothing happens: no second invocation starts, no write to `error`, no phase change. A third tap — the same. When the first invocation completes, the phase returns to `idle` and taps work again. + +``` +User Button activity.phase + │ tap │ │ idle + │────────────▶│ start │ + │ │──────────────▶│ running + │ tap │ │ + │────────────▶│ refused │ running (unchanged) + │ tap │ │ + │────────────▶│ refused │ running (unchanged) + │ │ …completes… │ + │ │──────────────▶│ idle +``` + +On the rooted screen, Save and Upload share the one `activity` — so while the save runs, a tap on Upload is refused the same way. Sharing an activity is how you declare "these buttons are one operation slot." + +The refusal comes from the `activity:` parameter. A button *without* one has no way to know work is in flight: every tap starts a new concurrent invocation, each following the traces above independently. + +**The rule this trace shows:** while an activity's work is in flight, its buttons refuse to start new work. + +## Tapping Cancel as the Upload Finishes + +The upload is running; the button reads Cancel; your finger is already descending. The upload completes first: the phase returns to `idle` and the face flips back to Upload. Your tap — aimed at Cancel — lands on Upload. + +The button ignores it. No invocation starts. A tap arriving in the instant after the faces change is treated as aimed at the old face and discarded; a deliberate tap a moment later starts normally. + +``` +User Button activity.phase + │ │ │ running — shows ✕ + │ │ …completes… │ + │ │──────────────▶│ idle — shows Start + │ tap (aimed │ │ + │ at ✕) │ │ + │────────────▶│ absorbed │ idle (no new invocation) + │ │ │ + │ tap (later)│ │ + │────────────▶│ start │ running +``` + +**The rule this trace shows:** a tap in the instant after a face change is ignored rather than misread against the old face. + +## Navigating Away While the Document Loads + +The screen appears; `task(id:error:)` writes `nil` into `error` and starts `loadDocument()`. Before it finishes, you navigate deeper into the app. SwiftUI cancels the task — that is `task`'s standing behavior, twin or not — and the load unwinds, typically throwing `CancellationError`. The invocation was cancelled, so nothing is deposited. + +You navigate back. The screen's `@State` — including `error`, still `nil` — survived in the `NavigationStack` while you were away, and on appearance `task` starts a fresh invocation. If the load's problem was momentary, this one succeeds. If the server is genuinely unreachable, this invocation fails *on the screen you are looking at*, deposits its error, and the alert presents it. + +``` +SwiftUI task invocation error + │ appear │ │ + │────────────────▶│ clear │ + │ │──────────────────▶│ nil + │ │ …loading… │ + │ disappear │ │ + │ (cancels) │ │ + │────────────────▶│ …unwinds, │ + │ │ throws │ + │ │ Cancellation- │ + │ │ Error… │ + │ │ (suppressed) │ nil — nothing + │ │ │ written + │ appear (back) │ │ + │────────────────▶│ runs again │ +``` + +Trace the alternative for one step to see what the held-back write protects: had the cancelled invocation deposited its `CancellationError`, that error would have sat in the surviving `@State` while you were away — and greeted your return with an alert about a load that was, at that same moment, already re-running. + +**The rule this trace shows:** teardown deposits nothing, and no durable failure is lost — a real problem recurs and presents on the next appearance. + +## Selecting a Different Document + +`loadDocument()` for document A is in flight when `viewModel.selectedDocumentId` changes to B. `task(id:error:)` responds the way `task(id:)` always does: it cancels invocation A and starts invocation B. B's first act is the `nil` write. A unwinds late — its task was cancelled, so however it finishes, it deposits nothing. B fails against the server; B's error is deposited; the alert presents it. + +``` +SwiftUI invocation A invocation B error + │ appear │ │ │ + │────────────────▶│ clear, run │ │ nil + │ │ …loading… │ │ + │ id changes │ │ │ + │ (cancels A) │ │ │ + │────────────────────────────────▶│ clear, run │ nil + │ │ …unwinds │ …loading… │ + │ │ late… │ │ + │ │ (suppressed) │ │ + │ │ │ …throws e… │ + │ │ │────────────▶│ e — B's + │ │ │ │ outcome +``` + +The order on the right edge is the point of the trace: A's unwind finished *after* B started, and `error` still holds only B's outcome. A cancelled invocation cannot write, so a superseded load can never speak over the current one — not with a `CancellationError`, and not with a stale failure about a document you are no longer viewing. + +**The rule this trace shows:** an `id` change starts a fresh invocation, and the superseded one contributes nothing. + +## When the Action Itself Throws `CancellationError` + +One last trace, to close the contract. + +Save is running. Nobody taps Cancel, nothing navigates away — the invocation's task is never cancelled. But inside the action, a child task the operation spawned gets cancelled, and its `CancellationError` escapes the action. The surface recognizes the language's cancellation sentinel and holds back: nothing is deposited, the phase returns to `idle`, and the discard is recorded in the debug log. The tap ends with no alert. + +``` +User Button activity.phase error + │ tap │ │ idle │ + │────────────▶│ clear │ │ + │ │───────────────┼────────────────▶│ nil + │ │──────────────▶│ running │ + │ │ child task │ │ + │ │ cancelled │ │ + │ │ inside the │ │ + │ │ action; │ │ + │ │ Cancellation-│ │ + │ │ Error escapes│ │ + │ │ (discarded, │ │ + │ │ logged) │ │ + │ │──────────────▶│ idle │ nil — no alert +``` + +This makes the quiet exit an idiom you can use on purpose: an action that decides mid-flight to end with nothing presented — the user declined a confirmation step, say — throws `CancellationError`, and the invocation finishes silently. + +The filter recognizes exactly one type: the language's own `CancellationError`. Errors that merely *describe* a cancellation in some domain's vocabulary — `URLError.cancelled`, for instance — deposit and present like any other failure. The framework recognizes the language's sentinel; it does not interpret your domain's error semantics. + +**The rule this trace shows:** a `CancellationError` never reaches `error` — cancellation, whatever its source, is not a fact the error channel carries. + +## The Contract, Collected + +The nine traces above demonstrate the full contract: + +- Starting an invocation writes `nil` into `error`. +- A thrown error lands in `error`; dismissing the alert clears it. +- Cancellation never reaches `error`: a cancelled invocation deposits nothing, and a `CancellationError` — the exact language type — is never deposited even when the invocation itself wasn't cancelled. +- Every discarded outcome is recorded in the debug log. +- Together: `error` always holds the outcome of the most recent invocation. +- `activity.phase` reports the work's lifecycle — `idle`, `running`, `cancelling` — and its `cancelling`-to-`idle` transition means the cancelled work has actually stopped. +- With an `activity:`, taps while work is in flight are refused, as is a tap in the instant after a two-faced button changes faces. + +## Topics + +- ``AsyncButtonActivity`` +- ``AsyncButtonActivity/Phase`` +- ``LocalizableError`` +- ``ViewModelOperations`` diff --git a/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift b/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift index dd607c5..61637b0 100644 --- a/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift +++ b/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift @@ -180,8 +180,14 @@ enum AsyncButtonEngine { failure = actionError } - if !Task.isCancelled, let failure { - error.wrappedValue = failure + if let failure { + if Task.isCancelled { + print("AsyncButton: discarding \(type(of: failure)) from a cancelled invocation") + } else if failure is CancellationError { + print("AsyncButton: discarding CancellationError from a non-cancelled invocation") + } else { + error.wrappedValue = failure + } } activity?.wrappedValue.finishRun(recordFlip: mode == .toggle) } diff --git a/Sources/FOSMVVM/SwiftUI Support/View+AsyncTask.swift b/Sources/FOSMVVM/SwiftUI Support/View+AsyncTask.swift new file mode 100644 index 0000000..793fcdf --- /dev/null +++ b/Sources/FOSMVVM/SwiftUI Support/View+AsyncTask.swift @@ -0,0 +1,129 @@ +// View+AsyncTask.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +import SwiftUI + +public extension View { + /// Async form of SwiftUI's `task(priority:_:)` — runs a throwing async action when the + /// view appears and routes its error to a binding + /// + /// ```swift + /// @State private var error: Error? + /// + /// var body: some View { + /// DocumentList(viewModel: viewModel) + /// .task(error: $error) { + /// try await viewModel.operations.loadDocuments() + /// } + /// .alert(error: $error, + /// title: viewModel.errorTitle, + /// dismissButtonLabel: viewModel.dismissTitle) + /// } + /// ``` + /// + /// The action starts when the view appears; a thrown error lands in `error`. Starting an + /// invocation clears `error` first — the binding always holds the outcome of the most + /// recent invocation. + /// + /// When the view disappears the task is cancelled, and a cancelled invocation writes + /// nothing to the binding — teardown never deposits a `CancellationError` into your + /// alert. + /// + /// To restart the load when a value changes, use ``task(id:error:priority:_:)``. For + /// work started by a tap, use the async `Button` forms — they pair with the same + /// binding. The full lifecycle contract, drawn situation by situation, is in + /// . + nonisolated func task( + error: Binding, + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async throws -> Void + ) -> some View { + task(priority: priority) { + await AsyncTaskEngine.run(error: error, action: action) + } + } + + /// Async form of SwiftUI's `task(id:priority:_:)` — restarts a throwing async action + /// whenever `id` changes and routes its error to a binding + /// + /// ```swift + /// @State private var error: Error? + /// + /// var body: some View { + /// DocumentDetail(viewModel: viewModel) + /// .task(id: viewModel.selectedDocumentId, error: $error) { + /// try await viewModel.operations.loadDocument() + /// } + /// .alert(error: $error, + /// title: viewModel.errorTitle, + /// dismissButtonLabel: viewModel.dismissTitle) + /// } + /// ``` + /// + /// The action starts when the view appears and restarts whenever `id` changes to a new + /// value; each start clears `error` first — the binding always holds the outcome of the + /// most recent invocation. + /// + /// A restart (or the view disappearing) cancels the in-flight invocation, and a + /// cancelled invocation writes nothing to the binding — a superseded load can never + /// overwrite the current invocation's outcome, and teardown never deposits a + /// `CancellationError` into your alert. + /// + /// The full lifecycle contract, drawn situation by situation, is in + /// . + nonisolated func task( + id: some Equatable, + error: Binding, + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async throws -> Void + ) -> some View { + task(id: id, priority: priority) { + await AsyncTaskEngine.run(error: error, action: action) + } + } +} + +/// The single home of the `task(error:)` semantics — both overloads forward here, so a +/// semantic change lands once and is never re-stamped. +enum AsyncTaskEngine { + /// @MainActor for the binding writes: unlike a body-site `.task` closure, the wrapper + /// closure above is defined in a nonisolated extension and inherits no actor context. + @MainActor static func run( + error: Binding, + action: @Sendable () async throws -> Void + ) async { + error.wrappedValue = nil + + var failure: (any Error)? + do { + try await action() + } catch let actionError { + failure = actionError + } + + if let failure { + if Task.isCancelled { + print("AsyncTask: discarding \(type(of: failure)) from a cancelled invocation") + } else if failure is CancellationError { + print("AsyncTask: discarding CancellationError from a non-cancelled invocation") + } else { + error.wrappedValue = failure + } + } + } +} +#endif diff --git a/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift index 7dbd92c..43e56d6 100644 --- a/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift +++ b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift @@ -87,6 +87,44 @@ struct AsyncButtonActivityTests { #expect(harness.activity.value.phase == .idle) } + // MARK: Cancellation sentinel + + @Test func cancellationError_fromNonCancelledInvocation_isDiscarded() async { + let harness = Harness() + + harness.tap(.refuse) { throw CancellationError() } + await harness.waitUntilIdle() + + #expect(harness.error.value == nil) + #expect(harness.activity.value.phase == .idle) + } + + @Test func domainCancellationVocabulary_isNotFiltered() async { + let harness = Harness() + + harness.tap(.refuse) { throw TestTapError.domainCancelled } + await harness.waitUntilIdle() + + #expect(harness.error.value as? TestTapError == .domainCancelled) + } + + @Test func cancelledInvocation_discardsEvenNonCancellationFailures() async { + let harness = Harness() + + harness.tap(.toggle) { + do { + try await Task.sleep(for: .seconds(600)) + } catch { + throw TestTapError.failed + } + } + harness.tap(.toggle) {} + await harness.waitUntilIdle() + + #expect(harness.error.value == nil) + #expect(harness.activity.value.phase == .idle) + } + // MARK: Refuse mode @Test func refuseMode_tapWhileRunning_hasNoObservableEffect() async { @@ -252,6 +290,7 @@ struct AsyncButtonActivityTests { private enum TestTapError: Error, Equatable { case previous case failed + case domainCancelled } /// Caller-side state (what a view's `@State` would hold) plus the tap entry point diff --git a/Tests/FOSMVVMTests/SwiftUI Support/AsyncTaskTests.swift b/Tests/FOSMVVMTests/SwiftUI Support/AsyncTaskTests.swift new file mode 100644 index 0000000..44cc67a --- /dev/null +++ b/Tests/FOSMVVMTests/SwiftUI Support/AsyncTaskTests.swift @@ -0,0 +1,220 @@ +// AsyncTaskTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +@testable import FOSMVVM +import Foundation +import SwiftUI +import Testing + +/// Each ratified `task(error:)` semantic gets a test, driven through AsyncTaskEngine.run — +/// the seam both public overloads forward to. SwiftUI's own `.task` lifecycle (start on +/// appear, cancel on disappear/id-change) is Apple's contract and is not re-tested here. +@MainActor +struct AsyncTaskTests { + // MARK: Clear-on-launch + + @Test func launch_clearsPreviousError_beforeTheActionCompletes() async { + let error = ValueBox(TestLoadError.previous) + let gate = Gate() + + let invocation = Task { @MainActor in + await AsyncTaskEngine.run(error: error.binding) { await gate.wait() } + } + + await waitUntil { error.value == nil } + gate.open() + await invocation.value + + #expect(error.value == nil) + } + + // MARK: Outcomes + + @Test func success_leavesErrorNil() async { + let error = ValueBox(nil) + + await AsyncTaskEngine.run(error: error.binding) {} + + #expect(error.value == nil) + } + + @Test func failure_landsInErrorBinding() async { + let error = ValueBox(nil) + + await AsyncTaskEngine.run(error: error.binding) { throw TestLoadError.failed } + + #expect(error.value as? TestLoadError == .failed) + } + + // MARK: Cancellation + + @Test func cancelledInvocation_writesNothing() async { + let error = ValueBox(nil) + let started = Gate() + + let invocation = Task { @MainActor in + await AsyncTaskEngine.run(error: error.binding) { + await started.open() + try await Task.sleep(for: .seconds(600)) + } + } + + await started.wait() + invocation.cancel() + await invocation.value + + #expect(error.value == nil) + } + + @Test func cancelledInvocation_discardsEvenNonCancellationFailures() async { + let error = ValueBox(nil) + let started = Gate() + + let invocation = Task { @MainActor in + await AsyncTaskEngine.run(error: error.binding) { + await started.open() + do { + try await Task.sleep(for: .seconds(600)) + } catch { + throw TestLoadError.failed + } + } + } + + await started.wait() + invocation.cancel() + await invocation.value + + #expect(error.value == nil) + } + + @Test func restart_supersededInvocationContributesNothing() async { + let error = ValueBox(nil) + let started = Gate() + let unwind = Gate() + + // Invocation A: cancelled mid-load, unwind held open so it finishes late + let invocationA = Task { @MainActor in + await AsyncTaskEngine.run(error: error.binding) { + await started.open() + do { + try await Task.sleep(for: .seconds(600)) + } catch { + await unwind.wait() + throw TestLoadError.stale + } + } + } + await started.wait() + invocationA.cancel() + + // Invocation B: the superseding load, which fails + await AsyncTaskEngine.run(error: error.binding) { throw TestLoadError.fresh } + #expect(error.value as? TestLoadError == .fresh) + + // A unwinds after B already deposited — B's outcome must survive + unwind.open() + await invocationA.value + #expect(error.value as? TestLoadError == .fresh) + } + + // MARK: Cancellation sentinel + + @Test func cancellationError_fromNonCancelledInvocation_isDiscarded() async { + let error = ValueBox(nil) + + await AsyncTaskEngine.run(error: error.binding) { throw CancellationError() } + + #expect(error.value == nil) + } + + @Test func domainCancellationVocabulary_isNotFiltered() async { + let error = ValueBox(nil) + + await AsyncTaskEngine.run(error: error.binding) { throw TestLoadError.domainCancelled } + + #expect(error.value as? TestLoadError == .domainCancelled) + } + + // MARK: Public surface (compiles + forwards) + + @Test func overloads_construct() { + let error = Binding.constant(nil) + + _ = Text("Probe").task(error: error) {} + _ = Text("Probe").task(error: error, priority: .background) {} + _ = Text("Probe").task(id: 7, error: error) {} + _ = Text("Probe").task(id: "doc", error: error, priority: .background) {} + } + + // MARK: Support + + private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async { + var yields = 0 + while !condition(), yields < 100000 { + await Task.yield() + yields += 1 + } + #expect(condition(), "condition not reached after \(yields) yields") + } +} + +// MARK: - Test Support + +private enum TestLoadError: Error, Equatable { + case previous + case failed + case stale + case fresh + case domainCancelled +} + +@MainActor +private final class ValueBox { + var value: V + + var binding: Binding { + Binding( + get: { self.value }, + set: { self.value = $0 } + ) + } + + init(_ value: V) { + self.value = value + } +} + +@MainActor +private final class Gate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func open() { + isOpen = true + continuations.forEach { $0.resume() } + continuations.removeAll() + } + + func wait() async { + if isOpen { + return + } + await withCheckedContinuation { continuations.append($0) } + } +} +#endif diff --git a/planning/stream/feat-task-error-twin.md b/planning/stream/feat-task-error-twin.md new file mode 100644 index 0000000..3fc20e7 --- /dev/null +++ b/planning/stream/feat-task-error-twin.md @@ -0,0 +1,180 @@ +# `.task(error:)` Twin — Implementation Plan + +**Status:** UNRATIFIED — awaiting David's review (the *design* was ratified decision-by-decision on 2026-08-20: both Apple overloads twinned; `error:` before `priority:`, `id:` stays first; hand-written `View` extension independent of `AsyncButtonEngine`; file `View+AsyncTask.swift`; error-routing only, no activity participation). + +**Ratification surface (David's ruling, 2026-08-21):** the lifecycle semantics — including the suppress-on-cancel rule and the task-state-not-error-type guard — are documented situation-by-situation with sequence diagrams in `Sources/FOSMVVM/FOSMVVM.docc/AsyncLifecycle.md` (drafted, uncommitted). David red-pens the article; the surviving text settles the open semantic rulings, and implementation projects from it. The article ships in the same PR as the twin. + +**Scope:** 2 hand-written `View.task` overloads that route a thrown error into the screen's error binding, plus the ship-time sweep of every site currently teaching the hand-caught interim (`grep "queued in FOSUtilities"`). + +**Carried rulings (button arc, do not re-litigate):** label `error:`, type `Binding` untyped; clear-on-launch ("the binding holds the outcome of the most recent invocation"); action `@escaping @Sendable () async throws -> Void`; a cancelled invocation writes nothing to the binding; presentation pairs with `alert(error:)` / `LocalizableError`. + +**Suppression rulings (2026-08-21, ratified in dialogue — supersede the parity-pin question):** + +- The deposit guard is `if !Task.isCancelled, let failure, !(failure is CancellationError)` — **both families**. Cancellation never reaches `error`, of any provenance: a cancelled invocation deposits nothing, and the language's `CancellationError` (the exact sentinel type, nothing wider) is never deposited even from a non-cancelled invocation. This is a **behavior change to the shipped 0.13.0 button engine** — David ratified it explicitly. +- Quiet-exit idiom is thereby supported on purpose: an action throws `CancellationError` to end with nothing presented. Documented in `AsyncLifecycle.md`. +- The filter stays sentinel-only: `URLError.cancelled` and other cancellation-flavored domain errors deposit normally — the framework recognizes the language's type, never interprets domain semantics. +- Every discarded outcome (task-cancelled discard AND sentinel-filtered discard) is logged at debug level, both families — following the shipped `print` notice precedent (`ErrorAlert.swift:126`). + +--- + +## 1. Public surface — every symbol justified + +All new API lives in `FOSMVVM`, `#if canImport(SwiftUI)`, file `Sources/FOSMVVM/SwiftUI Support/View+AsyncTask.swift` (ratified — parallels `Button+AsyncAction.swift`; hand-written, NOT `Generated/` — the sweep's input set is Localizable-titled surfaces and `.task` has no Localizable slot). + +Exactly two public symbols, twinning Apple's two `.task` overloads — Apple's labels and defaults kept verbatim, `error:` inserted before `priority:`, closure becomes `throws`: + +```swift +nonisolated func task( + error: Binding, + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async throws -> Void +) -> some View + +nonisolated func task( + id: T, + error: Binding, + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async throws -> Void +) -> some View +``` + +- Caller need, plain form: view-lifetime loads (`try await operations.loadData()`) without hand-caught `do/catch` — the documented interim this arc retires. +- Caller need, `id:` form: reload-on-parameter-change (refetch when a selection changes) — the call site where the interim goes wrong today: a restart's dying invocation deposits `CancellationError` into the alert. +- **Behavior contract:** invocation start clears the binding; a thrown error lands in it; cancellation never reaches the binding — a cancelled invocation writes nothing (view disappearance auto-cancels, and an `id` change cancels the prior invocation before starting the new one, so neither teardown nor restart can write), and a `CancellationError` from a non-cancelled invocation is filtered by sentinel type. Discards are debug-logged. +- **No activity parameter** (ratified): `.task`'s lifecycle is owned by SwiftUI (appear starts it, disappear/id-change cancels it) — no re-entry to refuse, no cancel face to render, so an activity binding would have nothing true to say. Loading UI stays view-state (`@State isLoading`, or `ViewModelView`'s own binding for VM fetches). A future loading-phase need composes onto this surface; it does not preempt it. +- **Sealed engine:** one internal seam both overloads forward to (working name `AsyncTaskEngine.run(error:action:)`, internal enum in the same file, mirroring `AsyncButtonEngine`'s forwarding shape without sharing its code — tap semantics don't exist here). Internal, never public. +- Existential note (governance flag, answered once in the button arc): `error: Binding` is the ratified currency — `any Error` is the language's error type at a UI boundary; typed throws ruled out (Apple's standing guidance for rendered-not-handled errors). + +### Encapsulation review (separate axis) + +Nothing to seal beyond the engine seam — the surface owns no state at all; its whole contract is the three binding rules. No representation exists to publish. + +--- + +## 2. Customer-facing DocC — drafted first + +Contract only; rationale lives in §4. + +### Plain form + +```swift +/// Async form of SwiftUI's `task(priority:_:)` — runs a throwing async action when the view +/// appears and routes its error to a binding +/// +/// ```swift +/// @State private var error: Error? +/// +/// var body: some View { +/// DocumentList(viewModel: viewModel) +/// .task(error: $error) { +/// try await viewModel.operations.loadDocuments() +/// } +/// .alert(error: $error, +/// title: viewModel.errorTitle, +/// dismissButtonLabel: viewModel.dismissTitle) +/// } +/// ``` +/// +/// The action starts when the view appears; a thrown error lands in `error`. Starting an +/// invocation clears `error` first — the binding always holds the outcome of the most +/// recent invocation. +/// +/// When the view disappears the task is cancelled, and a cancelled invocation writes +/// nothing to the binding — teardown never deposits a `CancellationError` into your alert. +/// +/// To restart the load when a value changes, use ``task(id:error:priority:_:)``. For work +/// started by a tap, use the async `Button` forms — they pair with the same binding. +``` + +### `id:` form + +```swift +/// Async form of SwiftUI's `task(id:priority:_:)` — restarts a throwing async action +/// whenever `id` changes and routes its error to a binding +/// +/// ```swift +/// @State private var error: Error? +/// +/// var body: some View { +/// DocumentDetail(viewModel: viewModel) +/// .task(id: viewModel.selectedDocumentId, error: $error) { +/// try await viewModel.operations.loadDocument() +/// } +/// .alert(error: $error, +/// title: viewModel.errorTitle, +/// dismissButtonLabel: viewModel.dismissTitle) +/// } +/// ``` +/// +/// The action starts when the view appears and restarts whenever `id` changes to a new +/// value; each start clears `error` first — the binding always holds the outcome of the +/// most recent invocation. +/// +/// A restart (or the view disappearing) cancels the in-flight invocation, and a cancelled +/// invocation writes nothing to the binding — a superseded load can never overwrite the +/// current invocation's outcome, and teardown never deposits a `CancellationError` into +/// your alert. +``` + +--- + +## 3. Contract tests + +`Tests/FOSMVVMTests/SwiftUI Support/AsyncTaskTests.swift` (Swift Testing). Precedent (button arc): semantics are driven through the internal engine seam every public overload forwards to — real `Binding` boxes, harness style of `AsyncButtonActivityTests.swift`; SwiftUI's own `.task` lifecycle (fires on appear, cancels on disappear/id-change) is Apple's contract and is not re-tested here. + +- clear-on-launch: pre-deposited error is `nil`ed when an invocation starts, before the action completes +- success: binding is `nil` after the invocation completes +- failure: thrown error lands in the binding +- cancelled invocation writes nothing: cancel the hosting task mid-action; after unwind the binding holds no `CancellationError` and no action error — including when the unwind throws a NON-cancellation failure (the raced-genuine-failure case) +- restart sequence: invocation A cancelled, invocation B fails → binding holds exactly B's error (the superseded invocation contributed nothing) +- sentinel filter: a `CancellationError` thrown by a NON-cancelled invocation is discarded — binding stays `nil` (the quiet-exit idiom) +- sentinel boundary: a non-`CancellationError` failure from a non-cancelled invocation deposits normally, even one that describes a cancellation in domain vocabulary (e.g. a `URLError.cancelled`-shaped error) +- both discard paths are covered in the button family too: extend `AsyncButtonActivityTests` for the sentinel filter (behavior change to the shipped engine) +- compile-surface: both public overloads called from a real `View` body (the `id:` form with an `Equatable` value), matching how generated-surface coverage is done today + +--- + +## 4. Rationale (implementer prose — none of this goes in DocC) + +**Why wrap Apple's `.task` rather than build on `onAppear`/`Task`:** SwiftUI already owns the lifecycle (start on appear, cancel on disappear, cancel-and-restart on `id` change); the twin adds exactly one behavior — error routing — so the implementation is a forwarding wrapper: `task(priority:) { await AsyncTaskEngine.run(error:action:) }`. + +**Why both overloads (ratified):** the `id:` variant is the reload-on-parameter-change workhorse and the call site most likely to carry the CancellationError-in-alert bug this arc exists to kill; its restart semantics fall straight out of the carried rulings, costing no new contract. + +**Why `error:` before `priority:` (ratified):** required parameter before defaulted ones; mirrors the button family where `error:` is the distinguishing required label; leaves `priority:` in Apple's defaulted position so a sync call site becomes error-routed by insertion only. `id:` stays first — it is Apple's selector for the overload. + +**Why independent of `AsyncButtonEngine` (ratified):** that enum is tap semantics — refuse/toggle modes, refractory window, activity phases — none of which exists here. The shared piece is only "clear, run, catch, write-unless-cancelled"; both homes pin that contract with their own tests rather than coupling for ~6 lines. + +**Why no activity type (ratified):** `AsyncButtonActivity` models a tap lifecycle; here there is no re-entry to refuse and no cancel face to render. A parameter with no behavior behind it is surface bloat; a proven consumer need composes on later. + +**Gotchas for the implementer:** + +- Apple's `.task` closure is `@_inheritActorContext @Sendable` — at a *body* call site it inherits MainActor, but our wrapper closure is defined inside a nonisolated extension method and inherits nothing. Mark the engine's `run` `@MainActor` so binding writes are main-side; the awaited `@Sendable` action hops off exactly as the button engine's does. +- The full deposit guard — `if !Task.isCancelled, let failure, !(failure is CancellationError)` — goes at the write site, after the catch; cancellation may arrive while the catch is unwinding. The same guard replaces the button engine's `if !Task.isCancelled, let failure` (`AsyncButtonActivity.swift:183` — the engine and hand-written primitives live in `AsyncButtonActivity.swift`; `Generated/Button+AsyncAction.swift` holds only the 12 titled forwarding inits and does not change). +- Discard logging follows the `ErrorAlert.swift:126` `print` precedent — one notice per discard naming the reason (invocation cancelled vs `CancellationError` filtered) and the error type. Behavior change + logging both get explicit CHANGELOG lines. +- Cancellation propagates from Apple's task into the awaited `run` — `Task.isCancelled` inside it reflects the `.task`'s cancellation; no handle plumbing needed. +- Overload resolution against Apple's sync family: the required `error:` label is the disambiguator — don't weaken it to a defaulted parameter. +- Verify `Package.swift` platform floors cover `.task` availability (iOS 15/macOS 12) before assuming no `@available` annotation is needed. +- Skill/doc examples obey the button-arc rules: `@Localized…` properties are plain values (`viewModel.title`, never `$title`); zero client references in any example or fixture. + +--- + +## 5. Decomposition (ordered tasks; PR gate once, at the end) + +1. **`AsyncLifecycle.md` ratification** — David red-pens the drafted article; its surviving text is the semantic contract (suppression rules incl. the sentinel filter, quiet-exit idiom, discard logging). DONE when ratified. +2. **Button engine: sentinel filter + discard logging** — replace the deposit guard in `AsyncButtonEngine` (`AsyncButtonActivity.swift:183`), add discard notices, extend `AsyncButtonActivityTests`. Behavior change to shipped 0.13.0 — its own logical commit with its own CHANGELOG line. Depends on 1. +3. **Twin surface + tests** — `Sources/FOSMVVM/SwiftUI Support/View+AsyncTask.swift` (engine + 2 overloads, DocC from §2) + `Tests/FOSMVVMTests/SwiftUI Support/AsyncTaskTests.swift` (§3, projected from the ratified article). Depends on 1; parallel with 2. +4. **Doc sweep** — retire every "queued in FOSUtilities but not yet shipped" interim, and cross-link `AsyncLifecycle.md` from `AsyncActionsAndErrors.md`: + - `.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md` — op-shape prose (~line 201), § Async Task Pattern (~line 635), comment (~line 1000) + - `.claude/skills/fosmvvm-swiftui-view-generator/reference.md` — Task on Appear (~line 996) + example bodies + - `Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md` — add the view-lifetime section (article already frames the button as the tap-path) + - `fosmvvm-ui-tests-generator` reference examples using hand-caught `.task { do/catch }` — modernize + - plugin version bump (skill docs changed) +5. **Bookkeeping** — `fosutilities-api-catalog-update` (FOSMVVM § SwiftUI Support entry + reach-for index line), CHANGELOG under `[Unreleased]` (minor — new public API + button behavior change), `swiftformat`/`swiftlint`, full `swift test`. + +Each task lands as granular local commits; squash to logical commits before the branch is offered for review. No PR until David reviews the finished queue and says go. + +--- + +## Open items + +1. Internal engine name — working name `AsyncTaskEngine`; internal-only, David may rename at readback.