Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 9 additions & 8 deletions .claude/skills/fosmvvm-swiftui-view-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 {
Expand All @@ -643,8 +643,8 @@ var body: some View {
contentView
}
}
.task {
do { try await loadData() } catch { self.error = error }
.task(error: $error) {
try await loadData()
}
}

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions .claude/skills/fosmvvm-swiftui-view-generator/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .claude/skills/fosmvvm-ui-tests-generator/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/fosutilities-api-catalog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
37 changes: 33 additions & 4 deletions .claude/skills/shared/api-catalog/FOSMVVM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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` <!-- apple-only -->
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()` <!-- apple-only -->
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
Expand Down
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error?>` (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

Expand Down
17 changes: 16 additions & 1 deletion Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <doc:Operations>), 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 <doc:AsyncLifecycle>.

## The Basic Wiring

One `@State` error per screen, fed by every async button on it, presented by one alert:
Expand Down Expand Up @@ -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 <doc:AsyncLifecycle>.

## 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:
Expand Down
Loading
Loading