From 285627785569e18951f121067ee0af93d9cf238c Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 19:58:31 +0530 Subject: [PATCH 1/7] fix: view-transition soft-nav meta leak and stuck Suspense skeleton Two related client-router defects, both triggered by view transitions during a soft navigation. #1046: the boundary-swap head merge (addNewHeadElements) was add-only, so a page-scoped the previous page declared (a view-transition opt-in, a per-page robots / theme-color / description, an og:* property) was never removed and leaked onto every later page. reconcileHeadMetas now gives each keyed meta add/update/remove treatment, keyed by name/property/http-equiv/ charset. It touches only tags, so the X-Webjs-Have reduced-head optimization (which omits the shared stylesheet, #936) is unaffected. #1048: with view transitions on, the swap runs inside startViewTransition, which defers the DOM mutation a frame. The Suspense resolve ran synchronously right after, against the pre-swap DOM (no placeholder yet), so the skeleton stuck. The buffered resolve (forwardSuspenseResolvers) now runs inside the swap thunk and resolves synchronously; the progressive streamed resolve is gated on a new swap-commit promise runWithTransition returns (updateCallbackDone), so it applies only after the placeholder is live. Verified against a scaffolded app: 8/8 stuck before, 0/8 after. Closes #1046 Closes #1048 --- packages/core/src/router-client.js | 144 ++++++++++++++++-- .../core/test/routing/router-client.test.js | 112 ++++++++++++++ 2 files changed, 245 insertions(+), 11 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index c3ce33a3..03c77687 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -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' } })); @@ -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(); } /** @@ -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} + */ +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 @@ -2775,9 +2798,10 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) 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 @@ -2815,7 +2839,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 @@ -2832,9 +2856,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; } @@ -2883,7 +2910,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) upgradeCustomElements(document.body); blurOutgoingFocus(); }; - runWithTransition(doSwap, () => upgradeCustomElements(document.body)); + _swapCommit = runWithTransition(doSwap, () => upgradeCustomElements(document.body)); } /** @@ -3708,6 +3735,72 @@ function outerHTMLForDiff(el) { return clone.outerHTML; } +/** + * Stable identity key for a `` 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 `` 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 `` 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". + * + * @param {HTMLHeadElement} newHead + */ +function reconcileHeadMetas(newHead) { + const incoming = new Map(); + for (const m of newHead.querySelectorAll('meta')) { + const key = metaIdentity(m); + if (key && !incoming.has(key)) incoming.set(key, m); + } + const live = new Map(); + for (const m of document.head.querySelectorAll('meta')) { + const key = metaIdentity(m); + if (key && !live.has(key)) live.set(key, m); + } + // Add a new keyed meta, or sync one whose attributes changed (e.g. content). + for (const [key, inc] of incoming) { + const cur = live.get(key); + if (!cur) { + document.head.appendChild(cloneElementWithCorrectNonce(inc)); + } else if (outerHTMLForDiff(inc) !== outerHTMLForDiff(cur)) { + for (const a of [...cur.attributes]) cur.removeAttribute(a.name); + for (const a of inc.attributes) cur.setAttribute(a.name, a.value); + } + } + // Remove a stale page-scoped keyed meta the incoming page does not declare. + for (const [key, cur] of live) { + if (!incoming.has(key)) cur.remove(); + } +} + function addNewHeadElements(newHead) { const newTitle = newHead.querySelector('title'); if (newTitle) document.title = newTitle.textContent || ''; @@ -3727,6 +3820,9 @@ function addNewHeadElements(newHead) { } if (el.tagName === 'BASE') continue; if (el.tagName === 'TITLE') continue; + // A keyed 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( @@ -3737,6 +3833,10 @@ function addNewHeadElements(newHead) { } } } + + // Reconcile keyed tags so a stale page-scoped meta is removed, not + // leaked onto every later page (#1046). + reconcileHeadMetas(newHead); } /** @@ -3848,7 +3948,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 `#` 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); } } @@ -3950,9 +4064,17 @@ function takeResolveUnit(buf) { * @param {string} id * @param {string} content */ -function applyStreamedResolve(id, content) { +function applyStreamedResolve(id, content, retry = true) { const boundary = document.getElementById(id); - if (!boundary) return; + if (!boundary) { + // The shell swap that places this placeholder can still be mid-commit inside + // an async `startViewTransition` (#1048): retry once on the next frame + // instead of silently dropping the boundary and leaving the skeleton stuck. + if (retry && typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => applyStreamedResolve(id, content, false)); + } + return; + } const tpl = document.createElement('template'); tpl.innerHTML = content; const inserted = [...tpl.content.childNodes]; diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 1d09ccd2..a4bad98f 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -687,6 +687,118 @@ test('addNewHeadElements: script elements are recreated (not cloned) to execute' assert.equal(added.getAttribute('type'), 'module'); }); +/* ==================================================================== + * addNewHeadElements: page-scoped reconciliation (#1046) + * ==================================================================== */ + +test('addNewHeadElements: removes a stale page-scoped meta absent from the incoming head (#1046)', () => { + // The previous page opted into view transitions; the incoming page does not. + document.head.innerHTML = + 'T' + + '' + + '' + + '' + + '' + + ''; + const newHead = document.createElement('head'); + newHead.innerHTML = + 'T' + + '' + + ''; + // NOTE: the incoming head omits both view-transition (page-scoped, dropped) + // AND the shared stylesheet (X-Webjs-Have reduction, must be KEPT, #936). + + _addNewHead(newHead); + + assert.equal( + document.head.querySelectorAll('meta[name="view-transition"]').length, 0, + 'stale page-scoped view-transition meta must be removed' + ); + // App-wide metas present in both heads survive. + assert.ok(document.head.querySelector('meta[charset="utf-8"]'), 'charset preserved'); + assert.ok(document.head.querySelector('meta[name="viewport"]'), 'viewport preserved'); + // #936 guard: a stylesheet absent from the reduced incoming head is NEVER removed. + assert.ok(document.head.querySelector('link[href="/app.css"]'), 'shared stylesheet must survive'); + assert.ok(document.head.querySelector('#runtime-css'), 'runtime CSS must survive'); +}); + +test('addNewHeadElements: updates a changed keyed meta in place, no duplicate (#1046)', () => { + document.head.innerHTML = 'T'; + const newHead = document.createElement('head'); + newHead.innerHTML = 'T'; + _addNewHead(newHead); + const robots = document.head.querySelectorAll('meta[name="robots"]'); + assert.equal(robots.length, 1, 'exactly one robots meta (updated, not duplicated)'); + assert.equal(robots[0].getAttribute('content'), 'noindex', 'content updated to the new page value'); +}); + +test('addNewHeadElements: adds a keyed meta present only in the incoming head (#1046)', () => { + document.head.innerHTML = 'T'; + const newHead = document.createElement('head'); + newHead.innerHTML = 'T'; + _addNewHead(newHead); + const vt = document.head.querySelectorAll('meta[name="view-transition"]'); + assert.equal(vt.length, 1, 'incoming page-scoped meta added'); + assert.equal(vt[0].getAttribute('content'), 'same-origin'); +}); + +test('addNewHeadElements: an og:* property meta is reconciled by property (#1046)', () => { + document.head.innerHTML = 'T'; + const newHead = document.createElement('head'); + newHead.innerHTML = 'T'; // incoming page declares no og:title + _addNewHead(newHead); + assert.equal( + document.head.querySelectorAll('meta[property="og:title"]').length, 0, + 'stale og:title removed when the incoming page does not declare it' + ); +}); + +/* ==================================================================== + * runWithTransition: swap-commit promise (#1048) + * + * The progressive Suspense streamer applies each resolved boundary against + * the swapped-in DOM. Under an async view transition the swap is deferred a + * frame, so the streamer must await the swap COMMIT or it runs against the + * pre-swap DOM (no placeholder) and the skeleton sticks. runWithTransition + * returns that commit signal. + * ==================================================================== */ + +test('runWithTransition: sync path runs the thunk and returns a resolved promise', async () => { + document.head.innerHTML = ''; // no view-transition meta -> VT disabled + const order = []; + const p = _runWithTransition(() => order.push('thunk'), () => order.push('after')); + assert.deepEqual(order, ['thunk', 'after'], 'thunk then afterFinished run synchronously'); + await p; // must be a thenable that resolves + assert.ok(true, 'returned a resolved promise on the sync path'); +}); + +test('runWithTransition: with an async view transition, the commit promise resolves AFTER the thunk (#1048)', async () => { + // Enable view transitions and mock an async startViewTransition that defers + // the DOM-mutation callback to a microtask (like the real API). + document.head.innerHTML = ''; + assert.equal(_viewTransitionsEnabled(), true, 'VT is enabled by the meta'); + + const prev = document.startViewTransition; + let thunkRan = false; + let committedBeforeThunk = null; + document.startViewTransition = (cb) => { + // Defer the update callback, exactly the ordering that stuck the skeleton. + const updateCallbackDone = Promise.resolve().then(() => { cb(); thunkRan = true; }); + return { updateCallbackDone, finished: updateCallbackDone, ready: updateCallbackDone }; + }; + try { + const commit = _runWithTransition(() => {}, () => {}); + // At this synchronous point the deferred thunk has NOT run yet. + committedBeforeThunk = thunkRan; + await commit; + assert.equal(committedBeforeThunk, false, 'thunk is deferred (async), not run synchronously'); + assert.equal(thunkRan, true, 'the commit promise only resolves after the thunk has committed'); + } finally { + document.startViewTransition = prev; + document.head.innerHTML = ''; + } +}); + test('addNewHeadElements: dynamically-created scripts get the meta csp-nonce, not the source page\'s per-request nonce', () => { // Set up the meta tag the server emits for the original page load. document.head.innerHTML = ''; From 4fa534d03a9381d42ad9cf74334abd8429fffbb5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 21:56:00 +0530 Subject: [PATCH 2/7] test: browser regression for VT soft-nav meta reconcile + Suspense resolve Real-browser (Chromium/Firefox/WebKit) coverage for #1046 and #1048: a stale page-scoped meta is removed on a soft nav, and a streamed Suspense boundary resolves under an async view transition instead of sticking. Counterfactual: the async-VT streamed case fails when the swap-commit gate is reverted. --- .../view-transition-head-and-suspense.test.js | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 packages/core/test/routing/browser/view-transition-head-and-suspense.test.js diff --git a/packages/core/test/routing/browser/view-transition-head-and-suspense.test.js b/packages/core/test/routing/browser/view-transition-head-and-suspense.test.js new file mode 100644 index 00000000..3dd633ae --- /dev/null +++ b/packages/core/test/routing/browser/view-transition-head-and-suspense.test.js @@ -0,0 +1,226 @@ +/** + * Real-browser regression tests for two view-transition soft-nav defects + * (#1046, #1048). Both need a real browser: linkedom models neither the + * streamed-shell swap pipeline nor the async View Transitions API timing that + * causes the bugs. + * + * #1046: a page-scoped `` (the `view-transition` opt-in, a per-page + * `robots` / og:*) the previous page declared must be REMOVED on a soft nav to + * a page that does not declare it, not leaked onto every later page. The + * add-only head merge never removed it; `reconcileHeadMetas` now does. + * + * #1048: with view transitions ON, `startViewTransition` defers the swap a + * frame, so a Suspense resolve that ran right after the swap call targeted the + * pre-swap DOM (no placeholder) and the skeleton stuck. The resolve is now + * gated on the swap COMMIT. We stub an ASYNC `startViewTransition` (deferring + * the callback to a microtask, exactly like the real API) so the race is real, + * and assert the streamed boundary resolves to content, not a stuck skeleton. + * + * We stub `window.fetch` to return the navigation response and drive a real + * link click so `applySwap` runs exactly as in prod. + */ +import { enableClientRouter } from '../../../src/router-client.js'; +import { assert } from '../../../../../test/browser-assert.js'; + +const tick = () => new Promise((r) => setTimeout(r, 0)); +async function settle() { for (let i = 0; i < 6; i++) await tick(); } + +const htmlResponse = (body) => Promise.resolve(new Response(body, { + headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, +})); + +function setViewTransitionMeta(on) { + let meta = document.querySelector('meta[name="view-transition"]'); + if (on) { + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', 'view-transition'); + document.head.appendChild(meta); + } + meta.setAttribute('content', 'same-origin'); + } else if (meta) { + meta.remove(); + } +} + +suite('Client router: page-scoped reconciliation on soft nav (#1046)', () => { + let container, origFetch, origSVT; + function setup() { + enableClientRouter(); + container = document.createElement('div'); + document.body.appendChild(container); + origFetch = window.fetch; + origSVT = document.startViewTransition; + } + function teardown() { + window.fetch = origFetch; + document.startViewTransition = origSVT; + setViewTransitionMeta(false); + // Clean any test-added app-wide metas. + for (const m of [...document.head.querySelectorAll('meta[name="robots"]')]) m.remove(); + container.remove(); + } + + test('a stale view-transition meta is removed when the incoming page does not declare it', async () => { + setup(); + try { + // The current page opted into view transitions. + setViewTransitionMeta(true); + container.innerHTML = + '' + + '' + + 'OLD' + + ''; + + // The incoming page carries the app-wide metas but NOT view-transition. + window.fetch = () => htmlResponse( + '' + + '' + + '' + + '' + + 'NEW' + + '' + ); + + document.getElementById('a-link').click(); + await settle(); + + assert.equal(document.querySelectorAll('meta[name="view-transition"]').length, 0, + 'the stale page-scoped view-transition meta is removed on the soft nav'); + assert.equal(document.getElementById('a-content').textContent, 'NEW', + 'the swap still applied'); + } finally { teardown(); } + }); + + test('an app-wide meta present in the incoming head is preserved (not churned)', async () => { + setup(); + try { + // A live app-wide robots meta that the incoming page also declares. + const robots = document.createElement('meta'); + robots.setAttribute('name', 'robots'); + robots.setAttribute('content', 'index,follow'); + document.head.appendChild(robots); + + container.innerHTML = + '' + + '' + + 'OLD' + + ''; + + window.fetch = () => htmlResponse( + '' + + '' + + '' + + 'NEW' + + '' + ); + + document.getElementById('b-link').click(); + await settle(); + + const live = document.querySelectorAll('meta[name="robots"]'); + assert.equal(live.length, 1, 'exactly one robots meta (kept, not duplicated)'); + assert.equal(live[0].getAttribute('content'), 'index,follow', 'content unchanged'); + } finally { teardown(); } + }); +}); + +suite('Client router: Suspense streaming resolves under view transitions (#1048)', () => { + let container, origFetch, origSVT; + + // A streamed navigation response. The shell (up to the shell sentinel) brings + // the #s1 skeleton placeholder in via a REPLACE-tier swap (the child boundary + // route-key changes /s/a -> /s/b), so the placeholder exists ONLY after the + // swap commits, never in the pre-nav DOM. The resolved boundary template + // follows the sentinel. This is what makes the swap-vs-resolve timing matter: + // if the resolve runs before the deferred view-transition swap commits, it + // finds no #s1 and the skeleton sticks. + const streamedBody = + // The target page ALSO opts into view transitions, so the meta survives the + // head reconcile and the swap genuinely runs under an async transition (the + // #1048 scenario). Without this the #1046 reconcile would drop the meta and + // turn transitions off before the swap, masking the race. + '' + + '' + + '' + + '
SKELETON
' + + '' + + '' + + '' + + '' + + ''; + + // Same streamed shell but WITHOUT the view-transition opt-in, for the + // synchronous-swap (no-regression) case. + const streamedBodyNoVT = streamedBody.replace( + '', ''); + + const liveShell = (linkId) => + '' + + '' + + `` + + '' + + ''; + + function setup() { + enableClientRouter(); + container = document.createElement('div'); + document.body.appendChild(container); + origFetch = window.fetch; + origSVT = document.startViewTransition; + } + function teardown() { + window.fetch = origFetch; + document.startViewTransition = origSVT; + setViewTransitionMeta(false); + container.remove(); + } + + // Stub an ASYNC startViewTransition: defer the DOM-mutation callback to a + // microtask and expose updateCallbackDone, exactly the timing that stuck the + // skeleton before the fix. + function stubAsyncSVT() { + document.startViewTransition = (cb) => { + const updateCallbackDone = Promise.resolve().then(() => cb()); + return { updateCallbackDone, finished: updateCallbackDone, ready: updateCallbackDone }; + }; + } + + test('a streamed Suspense boundary resolves (skeleton not stuck) with an async view transition', async () => { + setup(); + try { + setViewTransitionMeta(true); + stubAsyncSVT(); + container.innerHTML = liveShell('s-link'); + assert.ok(!document.getElementById('s1'), 'no placeholder before the nav (arrives via the swap)'); + + window.fetch = () => htmlResponse(streamedBody); + + document.getElementById('s-link').click(); + await settle(); + + assert.ok(document.getElementById('resolved'), + 'the streamed boundary resolved to its content'); + assert.equal(document.getElementById('resolved').textContent, 'RESOLVED', + 'content is the resolved boundary, not the skeleton'); + assert.ok(!document.getElementById('s1'), + 'the #s1 skeleton placeholder was replaced (not stuck)'); + } finally { teardown(); } + }); + + test('the same streamed nav resolves with view transitions OFF (no regression)', async () => { + setup(); + try { + setViewTransitionMeta(false); // sync swap path, and the target does not opt in either + container.innerHTML = liveShell('s2-link'); + + window.fetch = () => htmlResponse(streamedBodyNoVT); + + document.getElementById('s2-link').click(); + await settle(); + + assert.ok(document.getElementById('resolved'), 'boundary resolves on the synchronous path too'); + assert.ok(!document.getElementById('s1'), 'skeleton replaced'); + } finally { teardown(); } + }); +}); From 4f5b0b2d326fb2e1762707bc0a164f8fb338623c Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 22:01:01 +0530 Subject: [PATCH 3/7] docs: page-scoped meta reconcile + VT/Suspense composition on soft nav Update the client-router reference and docs-site page to reflect that the view-transition opt-in is a page-scoped meta reconciled on a soft nav (#1046), and that view transitions compose with Suspense streaming because the streamed resolve waits for the swap commit (#1048). --- .agents/skills/webjs/references/client-router-and-streaming.md | 2 ++ docs/app/docs/client-router/page.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index f8b1f533..2f896cb2 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -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 `` 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 `