Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A

The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.

The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).

## `<webjs-stream>` Surgical Updates

`<webjs-stream>` is WebJs's take on Turbo Streams, and the action set mirrors `<turbo-stream>`. It is the only SINGLE-element update primitive (append one row, remove one item, bump a count, insert a toast), whereas a frame or layout swap redraws a whole region.
Expand Down
3 changes: 2 additions & 1 deletion docs/app/docs/client-router/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) =&gt; renderStream(m) });</
<p>The router can wrap a client navigation's DOM mutation in the native <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API">View Transitions API</a> (<code>document.startViewTransition</code>), so a same-shell partial swap cross-fades (or runs your <code>::view-transition-*</code> CSS) instead of snapping. It is OFF by default and purely OPT-IN, so an unconfigured app behaves exactly as before (no animation surprise, no regression in a browser without the API). Opt in by adding a meta to the page head, mirroring Turbo's <code>&lt;meta name="view-transition"&gt;</code> convention:</p>
<pre>&lt;!-- in the root layout's &lt;head&gt;, or any page's head --&gt;
&lt;meta name="view-transition" content="same-origin"&gt;</pre>
<p>The accepted opt-in value is <code>same-origin</code> (every client-router swap is same-origin by construction, so it reads as "animate these in-app navigations"); any other value, or the meta being absent, keeps transitions off. The meta is re-read PER navigation, so a page can turn transitions on or off as the user moves through the app (the head merge brings in the new page's head).</p>
<p>The accepted opt-in value is <code>same-origin</code> (every client-router swap is same-origin by construction, so it reads as "animate these in-app navigations"); any other value, or the meta being absent, keeps transitions off. The opt-in is PER page, so it is a page-scoped meta: put it on one page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped <code>&lt;meta&gt;</code> tags (a stale one the previous page declared is removed, not left to leak).</p>
<p>View transitions COMPOSE with Suspense streaming: a streamed boundary (a <code>loading.ts</code> skeleton or a <code>&lt;webjs-suspense&gt;</code> region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before applying.</p>
<p>When enabled and supported, the transition wraps ALL THREE swap paths, the deepest-marker layout swap, the <code>&lt;webjs-frame&gt;</code> swap, AND the full-body fallback, not just the full-body case (the inverse of what an author expects, since the marker and frame swaps are the common designed-for paths). The transition wraps the DOM MUTATION ONLY, never the fetch (which already happened); the browser captures the before/after around the synchronous swap. When <code>startViewTransition</code> is unavailable (Firefox / older Safari), the swap runs synchronously, byte-identical to the no-transition path, with no flash and no throw.</p>

<h3>Persisting elements across a swap (<code>data-webjs-permanent</code>)</h3>
Expand Down
181 changes: 171 additions & 10 deletions packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2161,14 +2161,18 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
// now live, so stream the resolved boundaries in fast-before-slow. Detached
// (fire-and-forget) so the URL advance + navigate event do not wait on the
// slow boundary; each apply is guarded by the nav token so a newer navigation
// stops it.
// stops it. Gated on the swap COMMIT (`_swapCommit`): under an async view
// transition the shell swap is deferred a frame, so applying a resolve before
// the placeholder is in the DOM dropped the boundary and stuck the skeleton
// (#1048). On the synchronous path `_swapCommit` is already resolved, so this
// is a same-microtask no-op there.
if (streamCtx && (streamCtx.reader || streamCtx.rest)) {
streamBoundariesProgressively(
_swapCommit.then(() => streamBoundariesProgressively(
streamCtx.reader,
streamCtx.dec,
streamCtx.rest,
() => myToken === currentNavigationToken,
);
));
}

document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
Expand Down Expand Up @@ -2450,10 +2454,19 @@ function runWithTransition(thunk, afterFinished) {
} else if (afterFinished) {
afterFinished();
}
return;
// Resolve when the DOM MUTATION (the thunk) has actually committed, NOT when
// the animation finishes. Under `startViewTransition` the thunk is deferred a
// frame, so anything that reads the swapped-in DOM (a progressively-streamed
// Suspense resolve, #1048) must await this, or it runs against the pre-swap
// DOM and drops. `updateCallbackDone` is that signal; fall back to a resolved
// promise if the browser does not expose it.
return (t && t.updateCallbackDone && typeof t.updateCallbackDone.then === 'function')
? t.updateCallbackDone.catch(() => {})
: Promise.resolve();
}
thunk();
if (afterFinished) afterFinished();
return Promise.resolve();
}

/**
Expand Down Expand Up @@ -2597,6 +2610,16 @@ function upgradeCustomElementsInRange(range) {
}
}

/**
* Resolves when the most recent `applySwap` DOM mutation has committed. Under an
* async view transition the swap is deferred a frame, so the progressive
* Suspense streamer must await this before applying resolves, or it targets the
* pre-swap DOM (no placeholder yet) and drops the boundary (#1048). A resolved
* promise on the synchronous (no-transition) path.
* @type {Promise<void>}
*/
let _swapCommit = Promise.resolve();

function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) {
// SSR action seeding (#472): ingest any seed payload the incoming page
// carries BEFORE its components are grafted into the live DOM and upgrade, so
Expand Down Expand Up @@ -2771,13 +2794,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
// by node identity (it imports the incoming children first, then
// swaps the live permanent node into the imported tree), so the live
// `<audio>`/widget keeps running across the frame swap.
runWithTransition(() => {
// Capture the swap commit like the other swap paths so a frame nav that
// ALSO progressively streams a Suspense boundary gates its resolves on the
// committed frame swap, not a stale prior _swapCommit (#1048).
_swapCommit = runWithTransition(() => {
Comment thread
vivek7405 marked this conversation as resolved.
diffChildren(target, source);
reactivateScripts(target);
upgradeCustomElements(target);
// Inside the swap so the placeholder exists before we resolve (#1048).
forwardSuspenseResolvers(doc.body);
blurOutgoingFocus();
}, () => upgradeCustomElements(target));
forwardSuspenseResolvers(doc.body);
return;
}
// The response did not carry the requested frame (source null), or the
Expand Down Expand Up @@ -2815,7 +2842,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
// runtime state (Tailwind injection, etc.) must not be invalidated.
addNewHeadElements(doc.head);
runWithTransition(() => {
_swapCommit = runWithTransition(() => {
if (mode === 'replace') replaceBoundaryRange(live, incoming);
else swapMarkerRange(live, incoming, doc);
// No key sync is needed on the anchor's own comments: the plan's anchor
Expand All @@ -2832,9 +2859,12 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
// applySlotAssignments would wipe the freshly swapped content and
// restore the stale list.
resyncEnclosingHostSlots(live.start, incoming.start);
// Resolve buffered Suspense boundaries INSIDE the swap so the placeholder
// exists first. Doing this after `runWithTransition` returned raced the
// async view-transition swap and stuck the skeleton (#1048).
forwardSuspenseResolvers(doc.body);
blurOutgoingFocus();
}, () => upgradeCustomElementsInRange(live));
forwardSuspenseResolvers(doc.body);
return;
}

Expand Down Expand Up @@ -2883,7 +2913,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
upgradeCustomElements(document.body);
blurOutgoingFocus();
};
runWithTransition(doSwap, () => upgradeCustomElements(document.body));
_swapCommit = runWithTransition(doSwap, () => upgradeCustomElements(document.body));
}

/**
Expand Down Expand Up @@ -3708,6 +3738,109 @@ function outerHTMLForDiff(el) {
return clone.outerHTML;
}

/**
* The one framework-owned keyed meta that must NEVER be reconciled: the CSP
* nonce. A soft-nav response carries a FRESH per-request nonce, but the browser
* enforces CSP against the nonce the ORIGINAL page load declared (see
* `getCspNonce`), so overwriting the live `csp-nonce` meta with the incoming
* one would make every later nonce-stamped script/preload violate the active
* policy. Excluded from add/update/remove so the original meta survives verbatim.
*/
const META_KEY_CSP_NONCE = 'name=csp-nonce';

/**
* Stable identity key for a `<meta>` that represents a single logical tag, so a
* PAGE-SCOPED meta can be reconciled across a soft-nav head merge (#1046). A
* meta with no identifying attribute returns null and is left to the add-only
* path (added but never removed), since its identity is ambiguous.
*
* @param {Element} m
* @returns {string | null}
*/
function metaIdentity(m) {
const name = m.getAttribute('name');
if (name) return 'name=' + name;
const property = m.getAttribute('property');
if (property) return 'property=' + property;
const httpEquiv = m.getAttribute('http-equiv');
if (httpEquiv) return 'http-equiv=' + httpEquiv;
if (m.hasAttribute('charset')) return 'charset';
return null;
}

/**
* Reconcile keyed `<meta>` tags across a soft-nav head merge (#1046). The
* add-only merge (`addNewHeadElements`) never removes a stale head element, so a
* PAGE-SCOPED meta the previous page added (a `view-transition` opt-in, a
* per-page `robots` / `theme-color` / `description`, an `og:*` property) leaked
* onto every later page. This pass gives each keyed meta the full add / update /
* remove treatment: a meta present in the incoming head is added or synced, and
* a live keyed meta ABSENT from the incoming head is removed.
*
* Safe against the `X-Webjs-Have` reduced-head optimization (#936): that
* optimization only omits the shared app STYLESHEET (already on the client), and
* this pass touches ONLY `<meta>` tags, never a stylesheet / link / script. The
* incoming head always carries the target page's complete meta set (charset,
* viewport, and the app-wide metas from the root layout appear in both heads, so
* they are preserved), so "absent from the incoming head" means "this page does
* not declare it", not "optimized away".
*
* A key may repeat (multiple `og:image`), so both sides are grouped into a LIST
* per key and reconciled as a set: an unchanged set is left alone, else the live
* copies are removed and the incoming set re-appended.
*
* @param {HTMLHeadElement} newHead
*/
function reconcileHeadMetas(newHead) {
// A HEADLESS fragment response (a `<webjs-frame>` subtree) has no `<head>`, so
// `parseHTML` leaves `newHead` empty. A real full head ALWAYS emits charset +
// viewport, so "no `<meta>` at all in the incoming head" means "this is a
// fragment, not a head to reconcile against". Skipping it here is what keeps a
// frame swap from stripping every live page-scoped meta (viewport, og:*, ...).
if (!newHead.querySelector('meta')) return;
Comment thread
vivek7405 marked this conversation as resolved.

/** @param {ParentNode} root @returns {Map<string, Element[]>} */
const group = (root) => {
const map = new Map();
for (const el of root.querySelectorAll('meta')) {
const key = metaIdentity(el);
if (!key || key === META_KEY_CSP_NONCE) continue;
const list = map.get(key);
if (list) list.push(el); else map.set(key, [el]);
}
return map;
};
const incoming = group(newHead);
Comment thread
vivek7405 marked this conversation as resolved.
const live = group(document.head);

// Add or replace each incoming key whose SET differs from the live set.
for (const [key, incEls] of incoming) {
const liveEls = live.get(key) || [];
const incKey = incEls.map(outerHTMLForDiff).join('\n');
const liveKey = liveEls.map(outerHTMLForDiff).join('\n');
if (incKey === liveKey) continue;
if (incEls.length === 1 && liveEls.length === 1) {
// The common case (one description / theme-color / robots per page):
// sync attributes IN PLACE so the live element keeps its DOM identity.
// An app script holding a reference (a theme manager caching
// meta[name=theme-color]) still points at the live tag after the nav,
// and there is no remove/append churn for a content-only change.
const cur = liveEls[0];
for (const a of [...cur.attributes]) cur.removeAttribute(a.name);
for (const a of incEls[0].attributes) cur.setAttribute(a.name, a.value);
continue;
}
// Multi-element sets (repeated og:image): no unambiguous element-to-element
// mapping exists, so replace the set wholesale.
for (const el of liveEls) el.remove();
for (const el of incEls) document.head.appendChild(cloneElementWithCorrectNonce(el));
}
// Remove a stale page-scoped key the incoming page does not declare at all.
for (const [key, liveEls] of live) {
if (!incoming.has(key)) for (const el of liveEls) el.remove();
}
}

function addNewHeadElements(newHead) {
const newTitle = newHead.querySelector('title');
if (newTitle) document.title = newTitle.textContent || '';
Expand All @@ -3727,6 +3860,9 @@ function addNewHeadElements(newHead) {
}
if (el.tagName === 'BASE') continue;
if (el.tagName === 'TITLE') continue;
// A keyed <meta> is add/update/remove reconciled below (#1046), so skip it
// here to avoid appending a duplicate when its content changed.
if (el.tagName === 'META' && metaIdentity(el)) continue;
if (!currentSet.has(outerHTMLForDiff(el))) {
if (el.tagName === 'SCRIPT') {
document.head.appendChild(
Expand All @@ -3737,6 +3873,10 @@ function addNewHeadElements(newHead) {
}
}
}

// Reconcile keyed <meta> tags so a stale page-scoped meta is removed, not
// leaked onto every later page (#1046).
reconcileHeadMetas(newHead);
}

/**
Expand Down Expand Up @@ -3848,7 +3988,21 @@ function upgradeTree(root) {
*/
function forwardSuspenseResolvers(fetchedBody) {
for (const tpl of fetchedBody.querySelectorAll('template[data-webjs-resolve]')) {
document.body.appendChild(tpl.cloneNode(true));
const clone = /** @type {HTMLTemplateElement} */ (tpl.cloneNode(true));
document.body.appendChild(clone);
// Resolve SYNCHRONOUSLY against the just-swapped DOM instead of relying on
// the inline MutationObserver. The observer fires on a microtask, which
// races an async `startViewTransition` swap: with view transitions on, the
// swap that places the `#<id>` placeholder is deferred a frame, so the
// observer ran first, found no placeholder, and the skeleton stuck (#1048).
// Called from INSIDE the swap thunk (below), the placeholder is already in
// the DOM here, so this replaces it within the same commit (and inside any
// wrapping view transition, so the transition captures the resolved
// content, not the fallback). Falls back to the observer if the page-level
// resolver global is somehow absent.
const id = clone.getAttribute('data-webjs-resolve');
const resolve = /** @type {any} */ (window).__webjsResolve;
if (id && typeof resolve === 'function') resolve(id);
}
}

Expand Down Expand Up @@ -3952,6 +4106,13 @@ function takeResolveUnit(buf) {
*/
function applyStreamedResolve(id, content) {
const boundary = document.getElementById(id);
// A missing boundary is dropped (non-destructive), exactly as before. The
// async-view-transition race that USED to drop a still-valid boundary (#1048)
// is handled upstream: `streamBoundariesProgressively` is gated on the swap
// COMMIT (`_swapCommit`), so the placeholder is already live by the time any
// resolve is applied. A retry here would run OUTSIDE the streamer's
// `isCurrent()` nav-token fence and could splice a superseded nav's content
// into a recycled boundary id, so it is deliberately not attempted.
if (!boundary) return;
Comment thread
vivek7405 marked this conversation as resolved.
const tpl = document.createElement('template');
tpl.innerHTML = content;
Expand Down
Loading
Loading