diff --git a/active-rfcs/0045-composed-async.md b/active-rfcs/0045-composed-async.md new file mode 100644 index 00000000..980143fc --- /dev/null +++ b/active-rfcs/0045-composed-async.md @@ -0,0 +1,637 @@ +- Start Date: 2026-03-05 +- Target Major Version: 3.x +- Reference Issues: N/A +- Implementation PR: (leave this empty) + +# Summary + +Treat async reads and optimistic writes as states of Vue's reactive graph. + +- A readonly `computed()` getter may return a Promise. Vue exposes its fulfilled + result as the computed value and propagates pending and rejected states through + derived computations. +- The existing `` component turns a pending render into fallback UI + and keeps previously committed content visible during revalidation. +- A small `optimistic()` helper layers a tentative update over a ref or computed + value, then reconciles it on success or rolls it back on failure. + +The proposal adds no directive, component prop, SFC syntax, or separate memo +primitive. In particular, it does not add ` + + +``` + +The first read has no settled value, so `` renders its fallback. When +`userId` changes later, Vue keeps the committed profile visible while the next +reactive answer is prepared, then commits the new profile as one update. + +# Motivation + +Vue currently has two separate models: + +- synchronous values participate in the reactive graph; and +- asynchronous values are managed imperatively with loading refs, error refs, + request identity checks, and watchers, or they block an entire component via + async `setup()`. + +This split makes both reads and writes harder to compose. Applications rebuild +the same state machines for initial loading, revalidation, stale responses, +optimistic updates, rollback, and concurrent mutations. + +## Why `` remains incomplete + +The current +[`` documentation](https://vuejs.org/guide/built-ins/suspense.html) +describes a boundary around two kinds of dependencies: components with async +`setup()` and async components. That is useful, but it is not yet a general +model for asynchronous reactive state. + +### Dependencies are component-shaped + +A Promise returned from `computed()` is currently just another value. Derived +computed values receive the Promise itself, and `` cannot observe it. +Async work must therefore move into component setup merely to participate in a +loading boundary. + +This makes component boundaries carry data-flow semantics. Moving a request +between a parent, child, or composable can silently change which `` +owns it. + +### Async setup is implicit and too coarse + +Adding a top-level `await` to ` + + +``` + +The boundary discovers both reads through the graph and waits for both. Nested +`` boundaries can be used when the two regions should reveal +independently. + +## Promise props + +A component can adapt a Promise prop with the same primitive: + +```vue + + + +``` + +No directive or scoped slot is required. The boundary is supplied by whichever +ancestor owns the loading experience. + +## Optimistic updates + +Async reads need a settled-answer model. Mutations need the complementary +ability to publish a tentative answer immediately and either reconcile or remove +it later. + +This RFC adds one lowercase functional API: + +```ts +interface OptimisticOptions { + update: (current: T) => T + run: () => Result | PromiseLike + commit?: (current: T, result: Awaited) => T +} + +declare function optimistic( + target: Ref, + options: OptimisticOptions +): Promise> +``` + +`target` may be a mutable ref or a readonly computed ref. `optimistic()` does not +assign to a readonly computed value; it attaches an identified overlay to that +reactive source. Derived computed values and component renders observe the +overlay through ordinary reads. + +```vue + + + +``` + +The operation follows this lifecycle: + +1. The target must have a settled value. Calling `optimistic()` before the first + answer settles returns a rejected Promise and does not start `run`. +2. `update` is applied as a tentative layer and becomes visible immediately. + It does not enter `` pending state because it is a complete + provisional answer. +3. `run` performs the mutation and may cross an async boundary. +4. On fulfillment, `commit` receives this layer's optimistic value and the + fulfilled result. Its return value replaces the tentative layer. If `commit` + is omitted, the value produced by `update` becomes committed. +5. On rejection, the layer is removed, the previous answer becomes visible + again, and the Promise returned by `optimistic()` rejects with the same + reason. + +Both `update` and `commit` must be pure and replayable. This permits deterministic +overlap. Vue stores optimistic layers in invocation order. If an earlier +operation settles while later layers exist, Vue updates the base and replays the +later layers. If a later operation settles first, its layer remains ordered but +is marked fulfilled until earlier layers can be folded. Rejecting any layer +removes only that layer and rebases the remaining ones. + +When the underlying ref changes or an async computed generation fulfills, that +answer becomes the new base and active optimistic layers are replayed over it. +When no layers remain, the target behaves exactly as it did before the mutation. + +An active optimistic layer also supplies a complete answer while its underlying +computed source is revalidating. The source remains pending for diagnostics, but +that pending state does not hide or delay the tentative UI. + +This keeps mutation policy deliberately small. The helper does not define an API +transport, cache key, form action, retry policy, or persistence format. + +## Error handling + +`` remains a loading boundary, not an error boundary. If an async +computed Promise rejects, Vue reports the rejection through the existing +`onErrorCaptured` and `app.config.errorHandler` pipeline. + +An initial rejection prevents the pending branch from committing. A rejection +during refresh leaves the previous committed tree in place and reports the +error. The rejected computation can run again after one of its tracked inputs is +invalidated. + +This RFC does not add `#error`, `@error`, retry, or source-level error state to +``. + +A mutation rejection is separate from a read rejection. `optimistic()` first +rolls back its layer, then rejects its returned Promise so the event handler or +calling library can display an error. `` does not replace the retained +UI with loading or error content for that mutation. + +## Server rendering and hydration + +During SSR, a pending read causes the server renderer to await the current +generation and retry the affected branch, using the same boundary ownership as +async setup dependencies. + +This proposal does not define request caching or data serialization. A client +may therefore start the computation again during hydration. If server-rendered +content already exists, it is treated as the committed view and remains visible +until the client generation settles. A userland cache can avoid duplicate work. + +## Existing async setup + +Top-level `await` in `