0046: Async-aware view transitions
Status: Proposed Date: 2026-05-12
Context
ADR 0017 shipped manageNavTransitions()
with an explicit non-feature carry-out:
Async route handlers (a route view that depends on an unresolved
resource()) aren't fully captured. The transition completes its snapshot the moment the synchronous reactive watchers finish; data arriving later updates the post-transition DOM normally. For transition-aware data loading wrap the route handler logic in your ownshouldTransitionpredicate that defers until data is ready.
The shouldTransition workaround doesn't actually solve the problem
— it just gates whether the transition runs. There's no way for an
app to say "start the transition AND wait until the new route's data
has loaded before the browser samples the after-state."
The View Transitions API supports this natively: when
startViewTransition's callback returns a Promise, the browser
holds the snapshot until the promise settles, then samples the
"after" state and animates. Purity's wrapper currently passes a
sync callback so this capability is unused.
Apps with loader-driven routes (ADR 0022 + ADR 0025) want this:
the new route's loader() returns a Promise; the transition should
hold until that data lands, then animate from the previous fully-
rendered page to the new fully-rendered page. Without the await, the
transition animates from page A to a "loading" version of page B
(empty containers, fallbacks), then page B fills in afterward — a
visible flash.
Decision
Add awaitNavigation?: (url, replace) => unknown to
ManageNavTransitionsOptions. When supplied, the wrapper's
view-transition callback runs update() synchronously (URL signal
update + reactive watchers fire as before), then awaits the user's
thunk before returning. The browser holds the snapshot for the full
duration of the await.
manageNavTransitions({
awaitNavigation: async (url, replace) => {
// App-specific signal that the new route's data is ready.
await waitForRouteReady(url);
},
});
Concretely:
- Callback shape stays internal. The wrapper picks a sync or
async callback for
startViewTransitionbased on whetherawaitNavigationis set. No async-overhead penalty for apps that don't opt in. update()always runs synchronously first. The URL signal update happens before any await, matching the existing contract (route handlers see the new URL synchronously,onNavigatelisteners fire synchronously). Apps readingcurrentPath()insideawaitNavigationsee the new path.- Throwing or rejecting aborts the transition. The promise
returned by
startViewTransitionrejects; the browser cleans up the snapshot. The URL update already happened (it ran synchronously before the await), so the navigation is observable incurrentPath()/ history. This is the right behavior — apps shouldn't lose navigation state because a loader failed. - Sync return values are tolerated.
awaitNavigationis typed to returnunknownso apps can synchronously return any value (or nothing).await <non-promise>is just the value; no error. shouldTransition: falsestill short-circuits. When the predicate rejects the transition, the wrapper falls through to the plainupdate()path —awaitNavigationisn't called and no snapshot is taken.- Reduced-motion still short-circuits. Same path as
shouldTransition: false—awaitNavigationisn't called when the user has requested reduced motion.
Explicit non-features
- Per-route awaitNavigation. The hook is global — one thunk for
every nav. Apps that want per-route awaits inspect
urlinside their thunk and decide what to await. Adding a per-route override is consistent withshouldTransition's shape and is the obvious layer-up if a pattern emerges. - Default-on data awaiting. The thunk has no default — apps
must supply it. There's no framework-level "wait for all pending
resources" because the framework doesn't own the consumer's data
loading. Apps using ADR 0022's
loadernamed export can use the manifest'sroutes+ their own promise registry to implement this in user-land. - Snapshot timeout. The View Transitions API has its own ~4-second internal timeout after which the snapshot is discarded and the transition aborts. We don't add a Purity-level timeout on top — let the platform handle it.
- Cancel an in-flight transition on subsequent navigate. If a
user clicks two links in quick succession, the second wrapper
call kicks off a second
startViewTransition. The View Transitions API itself queues them; we don't add cancellation logic. Behavior matches every other shippingstartViewTransitionintegration.
Consequences
Positive:
- Closes the only ADR 0017 deferred non-feature that had a real pattern (async data-driven transitions). Apps with loader-driven routes can hold the snapshot until data lands, avoiding the flash-to-loading-state anti-pattern.
- The option is opt-in — zero overhead for apps that don't use it. The sync callback path stays sync, no async wrapper allocation.
- Composes with everything else:
shouldTransitionshort-circuits beforeawaitNavigationruns, reduced-motion short-circuits, teardown cleans up identically.
Negative:
- One more option on
ManageNavTransitionsOptions. Tree-shaking doesn't help here (the option is a flag inside the wrapper) but the cost is one extraifper nav. - Apps must wire up their own "data ready" signal. The framework
doesn't know when a route's data has settled — that's the
consumer's responsibility. Users following the ADR 0022 pattern
with
lazyResource+keyget a natural integration point (await the resource promise) but it's user-land glue.
Neutral:
- No new exports.
ManageNavTransitionsOptionsgains one field. - The wrapper's branch on
awaitNavigationis structural — TS picks the right callback shape per branch, no runtime check on the option inside the callback itself.
Alternatives considered
Make every wrapper callback async by default. Always
async () => { update(); }. Rejected: imposes async overhead on
apps that don't need it. The current sync path stays sync.
Add a separate manageNavLoaderTransitions() helper. Two
helpers, one for sync, one for loader-aware. Rejected: doubles the
API surface for the same primitive. One option flag on the existing
helper is cleaner.
Pass the pending-resource set as an argument to the thunk.
Framework collects all lazyResource().fetch() promises across the
render and hands them to awaitNavigation. Rejected for now:
requires plumbing through the renderer + the resource registry. The
manual approach (apps maintain their own promise registry) covers
the same use case without coupling. Could be added later if a
common pattern emerges.
Compose await + shouldTransition into one callback returning a
discriminated union. transition: (url, replace) => 'skip' | 'sync' | Promise<void>. Rejected: too clever, harder to read at call sites,
and the separation of "should it transition" from "how long should
the snapshot hold" is real.
Test surface
packages/core/tests/router-transitions.test.ts:
awaitNavigationis awaited inside the view-transition callback — the callback's returned promise doesn't settle until the user's thunk resolves.url+replaceflag are passed through.- Sync (non-Promise) return values are tolerated.
- A rejecting thunk lets the URL update land before the rejection propagates.
shouldTransition: falseshort-circuits beforeawaitNavigation.- Omitting
awaitNavigationkeeps the sync callback shape (no async wrapper allocation).