From 2029fa929e1be5b0d29e48cbd399464004c3f994 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 26 Jul 2026 20:06:34 +0530 Subject: [PATCH 1/5] fix(core): stale hover prefetch no longer degrades a click to a full load The intermittent whole-document flash on webjs.dev (#1114), tab spinner included, was the client router consuming a poisoned prefetch entry and then correctly bailing to location.href when the fragment shared no boundary with the live DOM. Two composing defects, both fixed: 1. The prefetcher could prefetch the page the user is ALREADY ON. The producer is a hover's intent timer surviving the navigation: hover a link, click it, the swap lands, then the timer fires and prefetches the destination against the destination's own boundaries. The server correctly answers "you have everything" with a near-empty fragment, which lands in the cache under the destination URL. prefetch() now no-ops when the target is the current page. 2. prefetchTake() ignored the X-Webjs-Have context an entry was fetched against. The server marks the reduced response Vary: X-Webjs-Have; the prefetch cache is a client-side cache of that response and must honor its own vary dimension. An entry whose stored have differs from the live buildHaveHeader() is now discarded, costing one round-trip on a stale hit instead of a full document load on consumption. Why it read as random: the poisoned entry lived under a 30s TTL, hovering again did NOT refresh it (the fresh-entry check skips refetching), a reload cleared the in-page cache, and the next lap re-poisoned it, so the reported cycle alternated clean and flashing laps. Reproduced 6-of-24 clicks before the fix, 0-of-24 after, same randomized hover dwells. Also ships the diagnostic that named the path: devWarnFallback becomes reportFallback, which dispatches a webjs:navigation-fallback event on document in EVERY environment ({ cause, href, willReload }) and keeps the dev-only console warning. The old warning was production-silent, which is why this class of bug was undiagnosable on the deployed site and got misattributed twice. The deploy-mismatch reload paths, which previously had no diagnostic at all, now report too. --- packages/core/src/router-client.js | 76 ++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 13df132bc..624730a85 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -764,15 +764,48 @@ function shouldFullLoadDuringParse(isPopState, frameId) { } /** - * Dev-only diagnostic: the client router degraded a soft navigation to a full - * page load. Records WHY (the `cause`) so the "why did my SPA nav do a full - * reload?" question is answerable from the console instead of guessed at. Silent - * in production and deduped per cause so a repeated trigger does not spam. + * The client router degraded a soft navigation. Records WHY (the `cause`), so + * "why did my SPA nav do a full reload?" is answerable instead of guessed at. + * + * Two channels, deliberately different in reach: + * + * - A **`webjs:navigation-fallback` event on `document`, in EVERY environment**, + * detail `{ cause, href, willReload }`. Dispatch convention matches + * `webjs:navigate` / `webjs:prefetch` / `webjs:navigation-error`. + * - A dev-only console warning, deduped per cause so a repeat does not spam. + * + * The event exists because the console warning alone made this class of bug + * UNDIAGNOSABLE in production (#1114). A degradation is correct behaviour, not + * an error, so nothing was logged and nothing was thrown, and a deployed app had + * no way to observe that a click had turned into a full document load. The + * user-visible symptoms (a loading spinner in the browser tab, a whole-document + * flash including preserved chrome) were then attributed to a styling problem + * for a full investigation cycle, because the actual cause emitted no signal. + * + * An event costs nothing when nobody listens, is greppable from a page console, + * and lets a deployed app wire this to analytics. It is NOT cancelable: by the + * time this fires the decision to degrade is already made and is the only safe + * option (#1015 chose a bounded full load over a heuristic recovery that could + * corrupt the DOM silently), so there is nothing for a listener to veto. * * @param {string} cause a short stable slug for the degradation reason * @param {string} href the destination the router fell back to loading + * @param {boolean} [willReload] true when a full document load follows (the + * default). False for a degradation that does NOT reload, so a listener can + * tell "this click became a document load" from "this background op was + * dropped", which are very different user-visible events. */ -function devWarnFallback(cause, href) { +function reportFallback(cause, href, willReload = true) { + if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') { + try { + document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', { + detail: { cause, href, willReload }, + })); + } catch { + // A listener that throws must never turn a correct degradation into a + // broken navigation. Diagnostics are strictly best-effort. + } + } if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return; warnOnce( `fallback:${cause}`, @@ -1137,7 +1170,7 @@ async function performNavigation(href, isPopState, frameId) { // Scoped to frameless forward navs: popstate is browser-driven, and a frame // nav carries its own boundary element. if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') { - devWarnFallback('readyState-loading', href); + reportFallback('readyState-loading', href); location.href = href; return; } @@ -1405,7 +1438,7 @@ const PREFETCH_HOVER_DELAY = 100; */ const PREFETCH_VIEWPORT_DELAY = 250; -/** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */ +/** @typedef {{ html: string, build: string | null, finalUrl: string, at: number, have: string }} PrefetchEntry */ /** @type {Map} */ const prefetchCache = new Map(); /** Keys with a fetch currently in flight (dedupe + concurrency gate). */ @@ -1588,6 +1621,16 @@ function prefetch(href) { if (typeof fetch !== 'function') return; if (prefetchSaysSaveData()) return; const key = cacheKey(href); + // Never prefetch the page we are already ON (#1114). This is not just waste: + // the reduced response for "a page the client fully has" is a near-empty + // fragment, and caching it poisons the URL-keyed entry for the NEXT visit. + // The concrete producer is a hover's intent timer surviving a navigation: + // hover a link, click it, the swap lands, THEN the timer fires and + // "prefetches" the destination against the destination's own boundaries. + // The next click on that link (from another page, within the TTL) consumed + // the poisoned fragment, found no shared boundary, and degraded to a full + // page load: the intermittent whole-document flash on webjs.dev. + if (typeof location !== 'undefined' && key === cacheKey(location.href)) return; if (prefetchInflight.has(key)) return; if (prefetchQueued.has(key)) return; const existing = prefetchCache.get(key); @@ -1652,7 +1695,7 @@ function prefetch(href) { } const finalUrl = resp.redirected && resp.url ? resp.url : href; const html = await resp.text(); - prefetchStore(key, { html, build, src, finalUrl, at: nowMs() }); + prefetchStore(key, { html, build, src, finalUrl, at: nowMs(), have }); }) .catch(() => { /* speculative: swallow */ }) .finally(() => { @@ -1713,6 +1756,16 @@ function prefetchTake(href) { if (!entry) return null; prefetchCache.delete(key); if ((nowMs() - entry.at) >= PREFETCH_TTL) return null; + // The reduced response VARIES on X-Webjs-Have (the server marks it so), and + // this cache is the client-side cache of that response, so it must respect + // its own vary dimension (#1114). An entry fetched against one page state is + // a fragment RELATIVE to that state; consuming it after the live boundaries + // changed (a navigation landed between the prefetch and the click) hands + // applySwap a fragment that shares no boundary with the live DOM, and the + // #1015 integrity degradation then correctly falls back to a full page load. + // Discarding here costs one network round-trip on a stale hit; consuming + // costs the user a full document load. + if ((entry.have || '') !== (buildHaveHeader() || '')) return null; return entry; } @@ -2753,14 +2806,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) // user is on a stale importmap but at least the page // renders. sessionStorage.removeItem(flag); + reportFallback('deploy-mismatch-reload-suppressed', href, false); } else { if (sessionStorage) sessionStorage.setItem(flag, '1'); + reportFallback('deploy-mismatch', href); location.href = href; return; } } catch { // sessionStorage unavailable (private mode w/ quota etc.): // fall through to a single reload like before. + reportFallback('deploy-mismatch', href); location.href = href; return; } @@ -2896,7 +2952,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) // is already viewing a page, so a background op must never yank them // through a hard load; it takes the in-place path below. if (href && !revalidating && typeof location !== 'undefined') { - devWarnFallback(!here ? 'live-boundaries-malformed' + reportFallback(!here ? 'live-boundaries-malformed' : !there ? 'incoming-boundaries-malformed' : 'no-shared-boundary', href); location.href = href; @@ -2909,7 +2965,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) // fragment, so the full-body swap below would wipe the shell from a // background op. Doing nothing is the only safe degradation here. if (href && revalidating) { - devWarnFallback('revalidation-discarded', href); + reportFallback('revalidation-discarded', href, false); return 'discard'; } From 860049ced8ade285f25f99c74b409507eb8957ba Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 26 Jul 2026 20:48:49 +0530 Subject: [PATCH 2/5] test(core): cover both stale-prefetch guards, unit and real browser Unit (packages/core/test/routing/router-client.test.js): the current-page guard including its path+search keying (a hash-only target is the same document and must be suppressed; a different search is a different page and must still warm), plus prefetchTake refusing a stale-context entry AND still consuming a matching one, so the guard cannot silently disable prefetching altogether. Browser (packages/core/test/routing/browser/prefetch-stale-context.test.js): the reported hover, swap, click sequence in DOM terms, asserting no consumable poison survives it. Real-browser because the entry only matters through buildHaveHeader() reading boundary comments out of a live document.body, and the failure mode is location.href, which linkedom does not model. Passes on Chromium, Firefox and WebKit. The browser test waits on the webjs:prefetch event rather than a bare setTimeout(0): the response body read is genuinely async in a browser, and the timeout version raced and failed intermittently across engines while writing it. Which is a small echo of the bug under test, so it is noted in the helper rather than silently worked around. --- .../browser/prefetch-stale-context.test.js | 194 ++++++++++++++++++ .../core/test/routing/router-client.test.js | 79 +++++++ 2 files changed, 273 insertions(+) create mode 100644 packages/core/test/routing/browser/prefetch-stale-context.test.js diff --git a/packages/core/test/routing/browser/prefetch-stale-context.test.js b/packages/core/test/routing/browser/prefetch-stale-context.test.js new file mode 100644 index 000000000..8b1556f62 --- /dev/null +++ b/packages/core/test/routing/browser/prefetch-stale-context.test.js @@ -0,0 +1,194 @@ +/** + * Real-browser regression for #1114: a stale prefetch entry must never degrade + * a click to a FULL PAGE LOAD. + * + * The reported symptom on webjs.dev was an intermittent flash of the entire + * document, navbar included, with a loading spinner in the browser tab. A tab + * throbber means a main-frame document load, which is exactly what the router + * does (correctly) when the #1015 boundary-integrity scan finds no trustworthy + * shared boundary. The captured cause on every failure was `no-shared-boundary`. + * + * The producer is a hover's intent-prefetch timer OUTLIVING the navigation it + * precedes: + * + * 1. Hover `/docs` on the landing page. The dwell timer starts. + * 2. Click. The soft nav lands, so the live DOM is now the docs page. + * 3. The timer fires anyway and prefetches `/docs`, computing `X-Webjs-Have` + * from the SWAPPED DOM. The server correctly answers "you already have + * everything" with a near-empty fragment, which is cached under `/docs`. + * 4. Back on the landing page, clicking `/docs` consumes that fragment, the + * swap finds no shared boundary, and the router full-loads. + * + * Two guards, one test each below, plus the end-to-end sequence. + * + * MUST run in a real browser: the poisoned entry only matters through + * `buildHaveHeader()` reading real boundary comments out of a live + * `document.body`, and the failure mode is `location.href`, which linkedom does + * not model. + */ +import { + enableClientRouter, + _prefetch, + _prefetchTake, + _prefetchPeek, + _buildHaveHeader, + _resetPrefetch, +} from '../../../src/router-client.js'; + +import { assert } from '../../../../../test/browser-assert.js'; + +/** + * Wait until a prefetch has actually landed in the cache, or until we can be + * confident none is coming. `prefetchStore` dispatches `webjs:prefetch` the + * instant a fragment becomes consumable, which is what makes this + * deterministic; a bare `setTimeout(0)` is NOT enough in a real browser, + * because the response body read is a genuine async operation (that raced and + * failed intermittently across engines while writing this test). + */ +function afterPrefetchAttempt(timeout = 1500) { + return new Promise((resolve) => { + let done = false; + const finish = () => { if (done) return; done = true; document.removeEventListener('webjs:prefetch', onStored); resolve(); }; + const onStored = () => finish(); + document.addEventListener('webjs:prefetch', onStored, { once: true }); + // Cap the wait so the "nothing should be cached" assertions still terminate. + setTimeout(finish, timeout); + }); +} + +/** The landing page's boundary shape: root layout only. */ +const HOME_BODY = + '' + + '
home
' + + ''; + +/** The docs shape: root layout, then the docs sub-layout, then the page. */ +const DOCS_BODY = + '' + + '' + + '
docs
' + + '' + + ''; + +suite('Client router: a stale prefetch never forces a full page load (#1114)', () => { + let origFetch, origBody, calls; + + function setup(bodyHtml) { + enableClientRouter(); + _resetPrefetch(); + origBody = document.body.innerHTML; + document.body.innerHTML = bodyHtml; + origFetch = window.fetch; + calls = []; + window.fetch = async (url, init) => { + calls.push({ url: String(url), have: (init && init.headers && init.headers['x-webjs-have']) || null }); + return new Response('' + DOCS_BODY + '', { + status: 200, + headers: { 'content-type': 'text/html', 'x-webjs-build': 'b1' }, + }); + }; + } + + function teardown() { + window.fetch = origFetch; + document.body.innerHTML = origBody; + _resetPrefetch(); + } + + test('prefetching the page you are already on is a no-op', async () => { + // Guard 1. Standing on the docs page, the late hover timer targets the docs + // page itself. Before the fix this issued a fetch whose response was the + // near-empty "you have everything" fragment, and cached it. + setup(DOCS_BODY); + try { + _prefetch(location.origin + location.pathname + location.search); + await afterPrefetchAttempt(150); + assert.equal(calls.length, 0, 'no fetch for the current page'); + assert.equal( + _prefetchPeek(location.origin + location.pathname + location.search), + null, + 'nothing cached under the current URL, so nothing to poison a later click' + ); + } finally { + teardown(); + } + }); + + test('an entry cached against a different boundary set is refused on consume', async () => { + // Guard 2, the belt to guard 1's braces: even if an entry with a stale + // context reaches the cache by any route, consuming it must not happen. + // Cache while the live DOM is the DOCS shape... + setup(DOCS_BODY); + try { + const target = location.origin + '/some-other-page'; + _prefetch(target); + await afterPrefetchAttempt(); + const cached = _prefetchPeek(target); + assert.ok(cached, 'precondition: fragment cached'); + assert.equal(cached.have, _buildHaveHeader(), 'and it recorded the docs-shaped context'); + + // ...then navigate (in DOM terms) back to the HOME shape, which is what + // happens between the poisoning prefetch and the later click. + document.body.innerHTML = HOME_BODY; + assert.notEqual(_buildHaveHeader(), cached.have, 'the live context really did change'); + + assert.equal( + _prefetchTake(target), + null, + 'the stale-context entry is refused, so the click refetches instead of full-loading' + ); + assert.equal(_prefetchPeek(target), null, 'and it is evicted rather than left to poison the next click'); + } finally { + teardown(); + } + }); + + test('the reported hover, swap, click sequence leaves no consumable poison', async () => { + // The end-to-end shape, in DOM terms. This is the assertion that would have + // caught the bug: after the full sequence there must be no entry that a + // click on /docs would consume. + setup(HOME_BODY); + try { + const docsUrl = location.origin + '/docs/intro'; + + // 1. Hover on home: a legitimate prefetch, home-shaped context. + _prefetch(docsUrl); + await afterPrefetchAttempt(); + assert.equal(calls.length, 1, 'the hover prefetch went out'); + assert.equal(calls[0].have, HOME_HAVE(), 'against the home boundaries'); + + // 2. The click consumes it: a hit, because the context still matches. + const hit = _prefetchTake(docsUrl); + assert.ok(hit, 'the hover prefetch is consumed as a hit (the feature still works)'); + + // 3. The swap lands: the live DOM is now the docs page. + document.body.innerHTML = DOCS_BODY; + + // 4. The stale hover timer fires while standing on /docs. This is the + // poisoning step, and guard 1 must make it inert. + _prefetch(docsUrl); + await afterPrefetchAttempt(150); + assert.equal( + _prefetchPeek(docsUrl), + null, + 'the late timer cached nothing, so returning home and clicking /docs cannot full-load' + ); + + // 5. Prove the next click is a clean cache MISS (refetch), not a + // poisoned hit. A miss is a round-trip; a poisoned hit was a reload. + document.body.innerHTML = HOME_BODY; + assert.equal(_prefetchTake(docsUrl), null, 'no consumable entry remains'); + } finally { + teardown(); + } + }); + + /** The `have` string the home-shaped body produces, computed live. */ + function HOME_HAVE() { + const saved = document.body.innerHTML; + document.body.innerHTML = HOME_BODY; + const have = _buildHaveHeader(); + document.body.innerHTML = saved; + return have; + } +}); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 5346af324..2627a9ea9 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -33,6 +33,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _restoreOptimistic, _navToken, _bumpNavToken, _currentPageUrl, _setCurrentPageUrl, _resetWarnOnce, _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, + _buildHaveHeader, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch, _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, _applyStreamedResolve, @@ -108,6 +109,7 @@ before(async () => { _prefetchHasHoverPointer, _prefetch, _prefetchTake, + _buildHaveHeader, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, @@ -3357,6 +3359,83 @@ test('prefetch: a cached entry is not re-fetched', async () => { }); }); +/* -------------------------------------------------------------------------- + * #1114: the prefetch cache must not poison a later navigation. + * + * The bug these pin: a hover's intent timer OUTLIVES the navigation it + * precedes. Hover a link, click it, the swap lands, then the timer fires and + * prefetches the destination while standing ON the destination. `X-Webjs-Have` + * is now computed from the swapped DOM, so the server correctly answers "you + * have everything" with a near-empty fragment, and that fragment is cached + * under the destination URL for the 30s TTL. The next click on that link from + * another page consumed it, `applySwap` found no shared boundary, and the + * router degraded to a full page load: the intermittent whole-document flash + * with a tab spinner on webjs.dev. + * ------------------------------------------------------------------------ */ + +test('prefetch: never fetches the page the user is already on (#1114, #1106)', async () => { + await withPrefetchEnv(async (calls) => { + // withPrefetchEnv pins location at http://localhost/ (pathname '/'). + _prefetch('http://localhost/'); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 0, 'no fetch issued for the current page'); + assert.equal(_prefetchPeek('http://localhost/'), null, 'and nothing cached under it'); + }); +}); + +test('prefetch: the current-page guard keys on path+search, not the full href', async () => { + await withPrefetchEnv(async (calls) => { + // A bare hash link is the same document, so it must be suppressed too; + // a different search IS a different page and must still prefetch. + _prefetch('http://localhost/#section'); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 0, 'hash-only target is the current page'); + + _prefetch('http://localhost/?q=1'); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 1, 'a different search is a different page'); + }); +}); + +test('prefetchTake: discards an entry fetched against a different X-Webjs-Have (#1114)', async () => { + await withPrefetchEnv(async () => { + _prefetch('http://localhost/about'); + await new Promise((r) => setTimeout(r, 0)); + const entry = _prefetchPeek('http://localhost/about'); + assert.ok(entry, 'precondition: the fragment is cached'); + // Simulate the poison: the entry was fetched while the live DOM had + // DIFFERENT boundaries than it does now. The stored `have` is the vary + // key of the reduced response, so a mismatch makes the fragment + // un-applicable to the current DOM. + entry.have = '/docs/getting-started:/docs/getting-started,/docs:/docs,/:/'; + assert.equal( + _prefetchTake('http://localhost/about'), + null, + 'a stale-context entry is refused, so the click refetches instead of full-loading' + ); + assert.equal( + _prefetchPeek('http://localhost/about'), + null, + 'and the poisoned entry is evicted, not left to poison the next click too' + ); + }); +}); + +test('prefetchTake: still consumes an entry whose X-Webjs-Have matches (#1114)', async () => { + await withPrefetchEnv(async () => { + // The guard must not break the feature it protects: a same-context entry + // is the whole point of prefetching and has to stay a cache HIT. + _prefetch('http://localhost/about'); + await new Promise((r) => setTimeout(r, 0)); + const stored = _prefetchPeek('http://localhost/about'); + assert.ok(stored, 'precondition: cached'); + assert.equal(stored.have, _buildHaveHeader() || '', 'stored context equals the live context'); + const taken = _prefetchTake('http://localhost/about'); + assert.ok(taken, 'consumed as a hit'); + assert.match(taken.html, /ok/); + }); +}); + test('prefetch: skips non-HTML and error responses', async () => { await withPrefetchEnv(async () => { _prefetch('http://localhost/json'); From 383171fd2b1573d52a51e67502496f846319ff56 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 26 Jul 2026 20:50:50 +0530 Subject: [PATCH 3/5] docs: document the context-keyed prefetch cache and the fallback event Two new public surfaces from #1114, on every doc surface that describes the client router. The prefetch cache is context-keyed, not just URL-keyed. A prefetched fragment is a reduced response varying on X-Webjs-Have, so an entry is a fragment RELATIVE to the DOM it was fetched against; the cache records that context and discards a mismatch on consume. Worth documenting rather than leaving internal, because the observable behaviour is "a prefetch sometimes does not produce a cache hit", which looks like a bug without the reason. webjs:navigation-fallback is genuinely new public API: dispatched on document in ALL environments whenever the router degrades a soft nav, with its cause. Documented alongside webjs:navigation-error, with the full cause list and the willReload flag that separates a click becoming a document load from a background op being dropped. Surfaces: AGENTS.md (the client-navigation paragraph), the skill's client-router-and-streaming reference (prefetch section + a new observing section), and website/app/docs/client-router. The scaffold copies the canonical skill at create time, so generated apps pick the reference up with no template change. --- .../webjs/references/client-router-and-streaming.md | 11 +++++++++++ AGENTS.md | 2 +- website/app/docs/client-router/page.ts | 12 ++++++++++++ 3 files changed, 24 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 7d67033a0..8ae0c52a5 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -65,6 +65,15 @@ document.addEventListener('webjs:navigation-error', (e) => { }); ``` +**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints. + +```ts +document.addEventListener('webjs:navigation-fallback', (e) => { + // A full document load on a click is a UX regression worth knowing about in prod. + if (e.detail.willReload) analytics.track('router_full_load', e.detail); +}); +``` + **Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events. ## Link Prefetch @@ -85,6 +94,8 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `
` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache. +**The cache is CONTEXT-KEYED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part, marking it `Vary: X-Webjs-Have`. So an entry is a fragment RELATIVE to the DOM it was fetched against, and the cache stores that context alongside it. On consume, a context mismatch DISCARDS the entry and the click refetches. That costs one round-trip; consuming it would hand the swap a fragment sharing no boundary with the live DOM, which correctly degrades to a full page load. The router also never prefetches the page it is already on, which is the main way a stale entry used to be created: a hover's intent timer can fire AFTER the click it belongs to has already swapped, at which point the destination is the current page and the server answers "you have everything" with a near-empty fragment. Both guards are internal; nothing to configure. + ## `` Partial-Swap Regions `` is WebJs's take on Turbo Frames, so most `` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express. diff --git a/AGENTS.md b/AGENTS.md index 2e2358195..9bf6e3edd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ type ActionResult = ## Client navigation: automatic, nothing to opt into -The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. +The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache honours that same vary dimension** (#1114): an entry records the `X-Webjs-Have` it was fetched against and is DISCARDED on consume if the live boundaries have since changed, because a fragment relative to a different DOM shares no boundary with this one and would force a full page load. The router also never prefetches the page it is already on, which is how a stale entry used to be created (a hover's intent timer firing after its own click already swapped). A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 48e1a11db..cf65feb4d 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -236,6 +236,18 @@ revalidate();

Only internal links qualify, using the same eligibility as a click: cross-origin, download, target other than _self, non-HTML extensions, data-no-router, and pure hash jumps are skipped. Opt out with data-prefetch="none", data-no-prefetch, or rel="external". Speculation is bounded (a concurrency cap with a draining queue, in-flight de-dupe, an LRU + TTL cache) and is disabled under Save-Data, prefers-reduced-data, or a 2g connection. A mutating form submission and revalidate() evict the prefetch cache too, so a fragment prefetched before a mutation is never served stale.

Prefetch issues a real GET, so a non-idempotent action (logout, anything that mutates) must be a POST or a <form>, never a GET link. This matches every framework that auto-prefetches. A native <link rel="prefetch"> in the document head is the browser's own mechanism and is left untouched.

+

The prefetch cache is context-keyed, not just URL-keyed

+

A prefetched fragment is a reduced response. The request carries X-Webjs-Have (the layout boundaries the client already holds) and the server returns only the divergent part, marking the response Vary: X-Webjs-Have. An entry is therefore a fragment relative to the DOM it was fetched against, so the cache stores that context alongside it and checks it on consume. A mismatch discards the entry and the click refetches: one round-trip, versus handing the swap a fragment that shares no boundary with the live DOM, which would correctly degrade to a full page load.

+

The router also never prefetches the page it is already on. That is the main way a stale entry used to appear: a hover's intent timer can fire after the click it belongs to has already swapped, at which point the destination is the current page, and the server answers "you already have everything" with a near-empty fragment. Both guards are internal and need no configuration.

+ +

Observing a degraded navigation (webjs:navigation-fallback)

+

Some conditions make a soft navigation impossible: the boundary-integrity scan finds no trustworthy shared segment, a click lands while the document is still parsing, or a cross-deploy build mismatch is detected. Rather than guess and risk a corrupt DOM, the router degrades to a full page load, which is bounded and correct but not soft.

+

Every such path dispatches webjs:navigation-fallback on document, in all environments including production, with detail { cause, href, willReload }. Causes are no-shared-boundary, live-boundaries-malformed, incoming-boundaries-malformed, readyState-loading, deploy-mismatch, deploy-mismatch-reload-suppressed, and revalidation-discarded. willReload is false for a degradation that does not reload (a dropped background revalidation), so a listener can tell a click that became a document load from a background op that was skipped. The event is not cancelable: by the time it fires, degrading is the only safe option. In development a deduped console warning also prints.

+
document.addEventListener('webjs:navigation-fallback', (e) => {
+  // A full document load on a click is a UX regression worth knowing about.
+  if (e.detail.willReload) analytics.track('router_full_load', e.detail);
+});
+

Per-segment loading skeletons

Each loading.{js,ts} in the route chain is rendered into a hidden <template id="wj-loading:<segment-path>"> at body end. On nav-start, the client clones the deepest matching template into the swap slot, so users see an instant per-segment skeleton during the fetch instead of stale content.

From 5a9ed7f5622b9133ad71cd65941ad5c523ff8b6a Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 26 Jul 2026 21:17:00 +0530 Subject: [PATCH 4/5] fix(core): validate a prefetch entry by its ANCHOR, not its whole have string Review of my own first attempt found the vary check was too strict and introduced two regressions. A reduced fragment is anchored at ONE boundary, the deepest the server short-circuited on, and applies to any live DOM that still offers that boundary with the same route-key. Comparing the whole X-Webjs-Have string instead discarded entries that would have applied: prefetch /docs/x from /, soft-nav to /blog, click, and the root-anchored fragment was thrown away for a round-trip main did not pay. Reachable in the ordinary mobile flow, since viewport is the touch default: warm the navbar, navigate once, tap. Worse, applyOptimisticLoading removes the page's own boundary pair before the fetch to insert a loading skeleton, so on any route with a loading.ts the live have is legitimately SHORTER at consume time than at prefetch time with no navigation at all. Every hover-then-click on such a route paid two requests, permanently. So prefetchTake now reads the fragment's anchor (its first open-boundary comment, via regex, since the full parse happens moments later anyway) and checks membership in buildHaveHeader(), which is the single source of truth for the segment:routeKey format. No anchor found means no constraint, so a future shape change degrades to the old permissive behaviour rather than rejecting everything. The entry is only deleted once a decision is made. Also from the same review: - handleNavigationError's last-resort location.href now reports too. It is a click becoming a document load, and three doc surfaces claim every path reports, so omitting it made the documented analytics recipe undercount. - The dev warning reads willReload instead of hardcoding "fell back to a full page load", which was wrong for the two causes that suppress the reload. - The three doc surfaces are corrected: they described the whole-string rationale, which was the thing that turned out to be wrong. Tests: prefetchAnchor unit coverage including the no-marker degrade path; a table-driven prefetchTake test over root-anchored-still-live, deep-anchored-gone, deep-anchored-still-live; and the new event finally gets coverage (cause + href + willReload, on both a reloading and a non-reloading degradation). The browser test that passed vacuously is replaced: it was short-circuited by the in-flight dedupe rather than the guard, and compared against a location.href the runner never has. --- .../references/client-router-and-streaming.md | 2 +- AGENTS.md | 2 +- packages/core/src/router-client.js | 92 +++++++++-- .../browser/prefetch-stale-context.test.js | 125 ++++++++------ .../core/test/routing/router-client.test.js | 154 ++++++++++++++---- website/app/docs/client-router/page.ts | 5 +- 6 files changed, 279 insertions(+), 101 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 8ae0c52a5..18f37658f 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -94,7 +94,7 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache. -**The cache is CONTEXT-KEYED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part, marking it `Vary: X-Webjs-Have`. So an entry is a fragment RELATIVE to the DOM it was fetched against, and the cache stores that context alongside it. On consume, a context mismatch DISCARDS the entry and the click refetches. That costs one round-trip; consuming it would hand the swap a fragment sharing no boundary with the live DOM, which correctly degrades to a full page load. The router also never prefetches the page it is already on, which is the main way a stale entry used to be created: a hover's intent timer can fire AFTER the click it belongs to has already swapped, at which point the destination is the current page and the server answers "you have everything" with a near-empty fragment. Both guards are internal; nothing to configure. +**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on, which is how the harmful entry used to be created: a hover's intent timer can fire AFTER the click it belongs to has already swapped, so it prefetches the destination while standing on it, and the server answers "you have everything" with a fragment anchored at that page's own boundary, which exists nowhere else. Both guards are internal; nothing to configure. ## `` Partial-Swap Regions diff --git a/AGENTS.md b/AGENTS.md index 9bf6e3edd..cb109e76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ type ActionResult = ## Client navigation: automatic, nothing to opt into -The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache honours that same vary dimension** (#1114): an entry records the `X-Webjs-Have` it was fetched against and is DISCARDED on consume if the live boundaries have since changed, because a fragment relative to a different DOM shares no boundary with this one and would force a full page load. The router also never prefetches the page it is already on, which is how a stale entry used to be created (a hover's intent timer firing after its own click already swapped). A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. +The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on, which is how the harmful entry used to be created (a hover's intent timer firing after its own click already swapped, producing a fragment anchored at that page's own boundary). A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 624730a85..e3f670f79 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -807,9 +807,15 @@ function reportFallback(cause, href, willReload = true) { } } if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return; + // Word the warning from `willReload`: several causes deliberately do NOT + // reload (a suppressed deploy reload, a discarded background revalidation), + // and claiming a full page load for those sends a reader hunting the wrong + // symptom. warnOnce( `fallback:${cause}`, - `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.` + willReload + ? `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.` + : `[webjs] client router degraded a soft navigation (${cause}) for ${href}, without a full page load.` ); } @@ -1439,6 +1445,9 @@ const PREFETCH_HOVER_DELAY = 100; const PREFETCH_VIEWPORT_DELAY = 250; /** @typedef {{ html: string, build: string | null, finalUrl: string, at: number, have: string }} PrefetchEntry */ +/* `have` is the X-Webjs-Have this fragment was fetched against. Kept for + * diagnostics and tests; validity is decided by the fragment's ANCHOR, not by + * this string (see prefetchTake). */ /** @type {Map} */ const prefetchCache = new Map(); /** Keys with a fetch currently in flight (dedupe + concurrency gate). */ @@ -1742,10 +1751,36 @@ function prefetchStore(key, entry) { } } +/** + * The `segment:routeKey` a cached fragment is ANCHORED at, or null when it + * carries no boundary (a full document, or an unparseable body). + * + * A reduced response begins at the deepest boundary the server short-circuited + * on, so its FIRST open-boundary comment is the join point the swap will look + * for in the live DOM. Verified against the server: `have=/:/` yields a + * fragment starting at `` (anchored at the root, so it + * applies on any page), while `have=/docs:/docs,/:/` yields one starting at + * `` (applies only under /docs). + * + * Read with a regex rather than a parse: this runs on the click path, and the + * full parse happens moments later in `fetchAndApply` anyway. The pattern is + * anchored on the literal marker the SSR emits, and a miss is treated as + * "no constraint", so a shape change degrades to the old permissive behaviour + * rather than silently rejecting every entry. + * + * @param {string} html + * @returns {string | null} + */ +function prefetchAnchor(html) { + const m = //.exec(html || ''); + return m ? m[1] : null; +} + /** * Consume a fresh speculative entry for `href`, removing it (a fragment * is single-use: once applied it becomes a real snapshot). Returns null - * on miss or when the entry has aged past the TTL. + * on miss, when the entry has aged past the TTL, or when the live DOM no + * longer offers the boundary the fragment is anchored at. * * @param {string} href * @returns {PrefetchEntry | null} @@ -1754,18 +1789,38 @@ function prefetchTake(href) { const key = cacheKey(href); const entry = prefetchCache.get(key); if (!entry) return null; + if ((nowMs() - entry.at) >= PREFETCH_TTL) { prefetchCache.delete(key); return null; } + // The reduced response VARIES on X-Webjs-Have, and this cache is a + // client-side cache of that response, so it has to respect its own vary + // dimension (#1114). The dimension is NOT the whole have string though: a + // fragment is anchored at ONE boundary and applies to any live DOM that still + // offers that boundary with the same route-key. Checking the whole string + // instead discards entries that would have applied (prefetch /docs/x from /, + // soft-nav to /blog, click: the fragment is anchored at the root, which /blog + // also has), and worse, `applyOptimisticLoading` removes the page's own + // boundary before the fetch to insert a loading skeleton, so on any route + // with a `loading.{js,ts}` the live have is legitimately SHORTER at consume + // time than at prefetch time with no navigation at all. + // + // So: consume when the anchor is still live, discard when it is not. + // Discarding costs one round-trip; consuming a fragment whose anchor is gone + // hands `applySwap` a tree sharing no boundary with the live DOM, and the + // #1015 integrity degradation correctly turns that into a full page load, + // which is the whole-document flash this guard exists to prevent. + const anchor = prefetchAnchor(entry.html); + if (anchor) { + // `buildHaveHeader()` emits exactly comma-joined `segment:routeKey` entries, + // so it is the single source of truth for the comparison format: membership + // in it IS "the live DOM offers this boundary with this route-key". It + // returns '' mid-parse, which rejects an anchored entry, and that is the + // safe direction (a click during parse takes the full-load path regardless). + const liveKeys = new Set(String(buildHaveHeader() || '').split(',').filter(Boolean)); + if (!liveKeys.has(anchor)) { + prefetchCache.delete(key); + return null; + } + } prefetchCache.delete(key); - if ((nowMs() - entry.at) >= PREFETCH_TTL) return null; - // The reduced response VARIES on X-Webjs-Have (the server marks it so), and - // this cache is the client-side cache of that response, so it must respect - // its own vary dimension (#1114). An entry fetched against one page state is - // a fragment RELATIVE to that state; consuming it after the live boundaries - // changed (a navigation landed between the prefetch and the click) hands - // applySwap a fragment that shares no boundary with the live DOM, and the - // #1015 integrity degradation then correctly falls back to a full page load. - // Discarding here costs one network round-trip on a stale hit; consuming - // costs the user a full document load. - if ((entry.have || '') !== (buildHaveHeader() || '')) return null; return entry; } @@ -1997,7 +2052,15 @@ function handleNavigationError(href, status, error) { // cross-document nav). Fall back to a hard load so an unrecoverable case // is not a silent dead-end. This is the exception, reached only after // the event was not cancelled AND no in-place target exists. - if (typeof location !== 'undefined') location.href = href; + // + // Report it like every other degradation (#1114): this IS a click turning + // into a document load, so an app watching `webjs:navigation-fallback` to + // count full loads must see it. The preceding `webjs:navigation-error` + // carries no `cause` / `willReload`, so it is not a substitute. + if (typeof location !== 'undefined') { + reportFallback('navigation-error-unrecoverable', href); + location.href = href; + } } /** @@ -4311,6 +4374,7 @@ export { prefetchHasHoverPointer as _prefetchHasHoverPointer, prefetch as _prefetch, prefetchTake as _prefetchTake, + prefetchAnchor as _prefetchAnchor, prefetchSaysSaveData as _prefetchSaysSaveData, readStreamedShell as _readStreamedShell, takeResolveUnit as _takeResolveUnit, diff --git a/packages/core/test/routing/browser/prefetch-stale-context.test.js b/packages/core/test/routing/browser/prefetch-stale-context.test.js index 8b1556f62..d06db545b 100644 --- a/packages/core/test/routing/browser/prefetch-stale-context.test.js +++ b/packages/core/test/routing/browser/prefetch-stale-context.test.js @@ -31,7 +31,6 @@ import { _prefetch, _prefetchTake, _prefetchPeek, - _buildHaveHeader, _resetPrefetch, } from '../../../src/router-client.js'; @@ -114,81 +113,99 @@ suite('Client router: a stale prefetch never forces a full page load (#1114)', ( } }); - test('an entry cached against a different boundary set is refused on consume', async () => { - // Guard 2, the belt to guard 1's braces: even if an entry with a stale - // context reaches the cache by any route, consuming it must not happen. - // Cache while the live DOM is the DOCS shape... + test('a fragment anchored at a boundary the live DOM still has IS consumed', async () => { + // The regression guard on the guard. A fragment prefetched from home is + // anchored at the ROOT boundary, which every page carries, so soft-navigating + // elsewhere before the click must NOT throw it away. An earlier version of + // this fix compared the whole `X-Webjs-Have` string and discarded it, which + // silently cost a round-trip on the common "warm the navbar, navigate once, + // then tap" flow, and broke prefetch outright on any route with a + // `loading.{js,ts}` (the skeleton removes the page boundary before the + // fetch, so the live have is legitimately shorter with no navigation). + setup(HOME_BODY); + try { + const target = location.origin + '/anchored-at-root'; + // Serve a ROOT-anchored fragment, which is what `have=/:/` returns. + window.fetch = async (url, init) => { + calls.push({ url: String(url), have: (init && init.headers && init.headers['x-webjs-have']) || null }); + return new Response('' + DOCS_BODY + '', { + status: 200, headers: { 'content-type': 'text/html', 'x-webjs-build': 'b1' }, + }); + }; + _prefetch(target); + await afterPrefetchAttempt(); + assert.ok(_prefetchPeek(target), 'precondition: cached'); + + // Soft-nav to a DIFFERENT page. The root boundary survives, as it does on + // every page in a real app, so the fragment is still applicable. + document.body.innerHTML = + '
blog
'; + + const taken = _prefetchTake(target); + assert.ok(taken, 'a root-anchored fragment survives an unrelated navigation and stays a cache HIT'); + } finally { + teardown(); + } + }); + + test('a fragment anchored at a boundary the live DOM LOST is refused', async () => { + // The other side of the same coin, and the actual #1114 shape: a fragment + // anchored deep (at /docs) cannot apply on a page with no /docs boundary. + // Consuming it is what produced the full page load. setup(DOCS_BODY); try { - const target = location.origin + '/some-other-page'; + const target = location.origin + '/docs/other'; + window.fetch = async (url, init) => { + calls.push({ url: String(url), have: (init && init.headers && init.headers['x-webjs-have']) || null }); + // A /docs-anchored fragment: what the server returns when the client + // already holds the root AND the docs layout. + return new Response( + '' + + '
other
' + + '', + { status: 200, headers: { 'content-type': 'text/html', 'x-webjs-build': 'b1' } }); + }; _prefetch(target); await afterPrefetchAttempt(); - const cached = _prefetchPeek(target); - assert.ok(cached, 'precondition: fragment cached'); - assert.equal(cached.have, _buildHaveHeader(), 'and it recorded the docs-shaped context'); + assert.ok(_prefetchPeek(target), 'precondition: cached'); - // ...then navigate (in DOM terms) back to the HOME shape, which is what - // happens between the poisoning prefetch and the later click. + // Leave the docs section entirely: no /docs boundary remains. document.body.innerHTML = HOME_BODY; - assert.notEqual(_buildHaveHeader(), cached.have, 'the live context really did change'); assert.equal( _prefetchTake(target), null, - 'the stale-context entry is refused, so the click refetches instead of full-loading' + 'the anchor is gone, so the entry is refused and the click refetches instead of full-loading' ); - assert.equal(_prefetchPeek(target), null, 'and it is evicted rather than left to poison the next click'); + assert.equal(_prefetchPeek(target), null, 'and it is evicted, not left to poison the next click'); } finally { teardown(); } }); - test('the reported hover, swap, click sequence leaves no consumable poison', async () => { - // The end-to-end shape, in DOM terms. This is the assertion that would have - // caught the bug: after the full sequence there must be no entry that a - // click on /docs would consume. - setup(HOME_BODY); + test('the reported sequence: a late same-page prefetch caches nothing', async () => { + // The end-to-end #1114 shape. Deliberately NOT reusing one in-flight window: + // an earlier version of this test called _prefetch twice synchronously, so + // the second call was swallowed by the in-flight dedupe rather than by the + // guard, and it passed on the unfixed bundle. Here the first prefetch is + // fully settled first, and the URL under test is the REAL current location, + // because guard 1 compares against location.href and no amount of + // document.body rewriting changes that. + setup(DOCS_BODY); try { - const docsUrl = location.origin + '/docs/intro'; - - // 1. Hover on home: a legitimate prefetch, home-shaped context. - _prefetch(docsUrl); - await afterPrefetchAttempt(); - assert.equal(calls.length, 1, 'the hover prefetch went out'); - assert.equal(calls[0].have, HOME_HAVE(), 'against the home boundaries'); + const here = location.origin + location.pathname + location.search; + assert.equal(calls.length, 0, 'clean slate'); - // 2. The click consumes it: a hit, because the context still matches. - const hit = _prefetchTake(docsUrl); - assert.ok(hit, 'the hover prefetch is consumed as a hit (the feature still works)'); + // The hover timer fires while standing on the page it points at. + _prefetch(here); + await afterPrefetchAttempt(200); - // 3. The swap lands: the live DOM is now the docs page. - document.body.innerHTML = DOCS_BODY; - - // 4. The stale hover timer fires while standing on /docs. This is the - // poisoning step, and guard 1 must make it inert. - _prefetch(docsUrl); - await afterPrefetchAttempt(150); - assert.equal( - _prefetchPeek(docsUrl), - null, - 'the late timer cached nothing, so returning home and clicking /docs cannot full-load' - ); - - // 5. Prove the next click is a clean cache MISS (refetch), not a - // poisoned hit. A miss is a round-trip; a poisoned hit was a reload. - document.body.innerHTML = HOME_BODY; - assert.equal(_prefetchTake(docsUrl), null, 'no consumable entry remains'); + assert.equal(calls.length, 0, 'no fetch: the late timer is inert'); + assert.equal(_prefetchPeek(here), null, 'nothing cached, so no later click can consume a self-fragment'); + assert.equal(_prefetchTake(here), null, 'and nothing to take'); } finally { teardown(); } }); - /** The `have` string the home-shaped body produces, computed live. */ - function HOME_HAVE() { - const saved = document.body.innerHTML; - document.body.innerHTML = HOME_BODY; - const have = _buildHaveHeader(); - document.body.innerHTML = saved; - return have; - } }); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 2627a9ea9..46c053892 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -32,7 +32,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, _restoreOptimistic, _navToken, _bumpNavToken, _currentPageUrl, _setCurrentPageUrl, _resetWarnOnce, - _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, + _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchAnchor, _buildHaveHeader, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch, _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, @@ -109,6 +109,7 @@ before(async () => { _prefetchHasHoverPointer, _prefetch, _prefetchTake, + _prefetchAnchor, _buildHaveHeader, _prefetchSaysSaveData, _prefetchPeek, @@ -3397,42 +3398,61 @@ test('prefetch: the current-page guard keys on path+search, not the full href', }); }); -test('prefetchTake: discards an entry fetched against a different X-Webjs-Have (#1114)', async () => { - await withPrefetchEnv(async () => { - _prefetch('http://localhost/about'); - await new Promise((r) => setTimeout(r, 0)); - const entry = _prefetchPeek('http://localhost/about'); - assert.ok(entry, 'precondition: the fragment is cached'); - // Simulate the poison: the entry was fetched while the live DOM had - // DIFFERENT boundaries than it does now. The stored `have` is the vary - // key of the reduced response, so a mismatch makes the fragment - // un-applicable to the current DOM. - entry.have = '/docs/getting-started:/docs/getting-started,/docs:/docs,/:/'; +test('prefetchAnchor: reads the boundary a reduced fragment is anchored at (#1114)', async () => { + await withPrefetchEnv(() => { + // A reduced response begins at the boundary the server short-circuited on, + // so its first open-boundary comment is the join point the swap looks for. assert.equal( - _prefetchTake('http://localhost/about'), - null, - 'a stale-context entry is refused, so the click refetches instead of full-loading' + _prefetchAnchor('
x
'), + '/docs:/docs' ); assert.equal( - _prefetchPeek('http://localhost/about'), - null, - 'and the poisoned entry is evicted, not left to poison the next click too' + _prefetchAnchor('
x
'), + '/:/' ); + // No boundary at all means "no constraint": a shape change must degrade to + // the old permissive behaviour, never to rejecting every entry. + assert.equal(_prefetchAnchor('
no markers
'), null); + assert.equal(_prefetchAnchor(''), null); }); }); -test('prefetchTake: still consumes an entry whose X-Webjs-Have matches (#1114)', async () => { +test('prefetchTake: keeps an entry whose anchor is still live, drops one whose anchor is gone (#1114)', async () => { await withPrefetchEnv(async () => { - // The guard must not break the feature it protects: a same-context entry - // is the whole point of prefetching and has to stay a cache HIT. - _prefetch('http://localhost/about'); - await new Promise((r) => setTimeout(r, 0)); - const stored = _prefetchPeek('http://localhost/about'); - assert.ok(stored, 'precondition: cached'); - assert.equal(stored.have, _buildHaveHeader() || '', 'stored context equals the live context'); - const taken = _prefetchTake('http://localhost/about'); - assert.ok(taken, 'consumed as a hit'); - assert.match(taken.html, /ok/); + // The validity key is the ANCHOR, not the whole X-Webjs-Have string. A + // root-anchored fragment applies on any page (every page carries the root + // boundary), so an unrelated navigation between prefetch and click must NOT + // throw it away; a /docs-anchored one cannot apply once /docs is gone. + // + // Driven through the real cache rather than by hand-poking entries, so it + // exercises prefetchTake's own read of the live DOM. + const live = (html) => { document.body.innerHTML = html; }; + const ROOT_ANCHORED = '
x
'; + const DOCS_ANCHORED = '
x
'; + + for (const [label, fragment, liveBody, expectHit] of [ + ['root-anchored, root still live', ROOT_ANCHORED, ROOT_ANCHORED, true], + ['docs-anchored, docs gone', DOCS_ANCHORED, ROOT_ANCHORED, false], + ['docs-anchored, docs still live', + DOCS_ANCHORED, + '' + DOCS_ANCHORED + '', true], + ]) { + _resetPrefetch(); + await withPrefetchEnv(async () => { + _prefetch('http://localhost/target'); + await new Promise((r) => setTimeout(r, 0)); + const e = _prefetchPeek('http://localhost/target'); + assert.ok(e, `${label}: precondition cached`); + e.html = fragment; // pin the anchor under test + live(liveBody); + const took = _prefetchTake('http://localhost/target'); + assert.equal(!!took, expectHit, label); + if (!expectHit) { + assert.equal(_prefetchPeek('http://localhost/target'), null, `${label}: evicted, not left to poison`); + } + }); + } + document.body.innerHTML = ''; }); }); @@ -3846,6 +3866,82 @@ function faultInjectionCase(t, liveBody, incomingBody) { } } +/* -------------------------------------------------------------------------- + * #1114: every degradation is OBSERVABLE in production. + * + * The bug this exists to prevent recurring: the router degraded correctly but + * SILENTLY (the warning was dev-only), so a click turning into a full document + * load emitted no signal on a deployed site. That is why the whole-document + * flash was misattributed twice before the real cause was found. These pin the + * event as public API, not as debug output that can be quietly dropped. + * ------------------------------------------------------------------------ */ + +test('applySwap: a degradation dispatches webjs:navigation-fallback with its cause (#1114)', () => { + const savedBody = globalThis.document.body.innerHTML; + const savedHead = globalThis.document.head.innerHTML; + const savedLocation = globalThis.location; + const seen = []; + const onFallback = (e) => seen.push(e.detail); + globalThis.document.addEventListener('webjs:navigation-fallback', onFallback); + try { + globalThis.document.head.innerHTML = ''; + globalThis.location = /** @type any */ ({ get href() { return 'http://x/current'; }, set href(_v) {} }); + globalThis.sessionStorage.clear(); + // Live side is fine; the INCOMING side lost its close marker, so the scan + // is poisoned and the only safe move is a full page load. + globalThis.document.body.innerHTML = + '
old
'; + const incoming = new globalThis.DOMParser().parseFromString( + '
new
', 'text/html'); + + _applySwap(incoming, null, false, 'http://x/blog'); + + assert.equal(seen.length, 1, 'exactly one event for one degradation'); + assert.equal(seen[0].cause, 'incoming-boundaries-malformed', 'the cause names the actual reason'); + assert.equal(seen[0].href, 'http://x/blog', 'and the destination that will now hard-load'); + assert.equal(seen[0].willReload, true, 'this one really does reload, which is what an app wants to count'); + } finally { + globalThis.document.removeEventListener('webjs:navigation-fallback', onFallback); + globalThis.location = savedLocation; + globalThis.document.head.innerHTML = savedHead; + globalThis.document.body.innerHTML = savedBody; + } +}); + +test('applySwap: a discarded background revalidation reports willReload=false (#1114)', () => { + const savedBody = globalThis.document.body.innerHTML; + const savedHead = globalThis.document.head.innerHTML; + const savedLocation = globalThis.location; + const seen = []; + const onFallback = (e) => seen.push(e.detail); + globalThis.document.addEventListener('webjs:navigation-fallback', onFallback); + try { + globalThis.document.head.innerHTML = ''; + let assigned = null; + globalThis.location = /** @type any */ ({ get href() { return 'http://x/current'; }, set href(v) { assigned = v; } }); + globalThis.sessionStorage.clear(); + globalThis.document.body.innerHTML = + '
old
'; + const incoming = new globalThis.DOMParser().parseFromString( + '
new
', 'text/html'); + + // revalidating=true: a background refresh with no trustworthy plan is + // DISCARDED rather than hard-loaded, because the user is already looking at + // a valid page. The distinction matters to a listener counting full loads. + _applySwap(incoming, null, true, 'http://x/blog'); + + assert.equal(assigned, null, 'a background op never yanks the user through a hard load'); + assert.equal(seen.length, 1, 'still reported, so the degradation is not invisible'); + assert.equal(seen[0].cause, 'revalidation-discarded'); + assert.equal(seen[0].willReload, false, 'and flagged as NOT a document load'); + } finally { + globalThis.document.removeEventListener('webjs:navigation-fallback', onFallback); + globalThis.location = savedLocation; + globalThis.document.head.innerHTML = savedHead; + globalThis.document.body.innerHTML = savedBody; + } +}); + test('applySwap: a truncated INCOMING boundary (dropped close) degrades to a full load (#1015)', (t) => { faultInjectionCase(t, '' + diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index cf65feb4d..e48f9b58d 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -236,8 +236,9 @@ revalidate();

Only internal links qualify, using the same eligibility as a click: cross-origin, download, target other than _self, non-HTML extensions, data-no-router, and pure hash jumps are skipped. Opt out with data-prefetch="none", data-no-prefetch, or rel="external". Speculation is bounded (a concurrency cap with a draining queue, in-flight de-dupe, an LRU + TTL cache) and is disabled under Save-Data, prefers-reduced-data, or a 2g connection. A mutating form submission and revalidate() evict the prefetch cache too, so a fragment prefetched before a mutation is never served stale.

Prefetch issues a real GET, so a non-idempotent action (logout, anything that mutates) must be a POST or a <form>, never a GET link. This matches every framework that auto-prefetches. A native <link rel="prefetch"> in the document head is the browser's own mechanism and is left untouched.

-

The prefetch cache is context-keyed, not just URL-keyed

-

A prefetched fragment is a reduced response. The request carries X-Webjs-Have (the layout boundaries the client already holds) and the server returns only the divergent part, marking the response Vary: X-Webjs-Have. An entry is therefore a fragment relative to the DOM it was fetched against, so the cache stores that context alongside it and checks it on consume. A mismatch discards the entry and the click refetches: one round-trip, versus handing the swap a fragment that shares no boundary with the live DOM, which would correctly degrade to a full page load.

+

The prefetch cache is anchor-validated, not just URL-keyed

+

A prefetched fragment is a reduced response. The request carries X-Webjs-Have (the layout boundaries the client already holds) and the server returns only the divergent part, starting at the deepest boundary it short-circuited on. That boundary is the fragment's anchor, and the fragment applies to any live DOM that still offers it with the same route-key.

+

So on consume the router validates the anchor, not the whole have string. A fragment anchored at the root layout survives an unrelated navigation and stays a cache hit, because every page carries the root boundary. One anchored at /docs is discarded once you leave the docs section: applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip, which is the cheap side of that trade.

The router also never prefetches the page it is already on. That is the main way a stale entry used to appear: a hover's intent timer can fire after the click it belongs to has already swapped, at which point the destination is the current page, and the server answers "you already have everything" with a near-empty fragment. Both guards are internal and need no configuration.

Observing a degraded navigation (webjs:navigation-fallback)

From d36c8f9e34d3ec3daf0a46fb4d1a162d78e84baa Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 26 Jul 2026 21:39:03 +0530 Subject: [PATCH 5/5] fix(core): validate the anchor against the pre-skeleton boundaries Second review round found the anchor check was still judging against the wrong view of the DOM, and that the causal story I had written was wrong in a way that mattered. applyOptimisticLoading deletes everything between the chosen slot's markers, which includes every NESTED boundary comment, and it runs BEFORE the fetch. So on an app whose only loading.{js,ts} sits at the root, buildHaveHeader() afterwards reports just the root and every deeper anchor looks dead: no prefetch is ever consumable on that whole app. The skeleton now captures the boundary keys before it deletes them, on the state that already threads to fetchAndApply, and prefetchTake validates against that when present. The narrative correction, which is the more important half. I had written that a self-prefetch returns a near-empty fragment anchored at the page's own boundary, and that not prefetching the current page was therefore the cure. Both wrong. The server short-circuits on LAYOUT segments and ignores the page's own boundary entry, so a self-prefetch of /docs/x while holding the docs layout returns a normal 46KB fragment anchored at /docs:/docs, byte identical to a legitimate sibling prefetch. It applies fine from a sibling /docs page and fails only from outside /docs. So the anchor check is the cure and the current-page guard is not; it removes one producer and a wasted request (which is #1106's point). The other producer it does not touch at all: hover a sibling link, never click it, then leave the section. That path now has its own browser test, because it is the one that proves which guard is doing the work. Also from the round: the new navigation-error-unrecoverable cause was missing from both documented cause lists, so an app filtering on the documented set would have dropped exactly the path this PR added reporting to; the have field on PrefetchEntry was dead once validation moved to the anchor and is removed rather than left with a comment claiming a purpose; and the comment at the consume site still described the pre-change same-shell model. --- .../references/client-router-and-streaming.md | 4 +- AGENTS.md | 2 +- packages/core/src/router-client.js | 77 ++++++++---- .../browser/prefetch-stale-context.test.js | 110 +++++++++++++++--- .../core/test/routing/router-client.test.js | 18 +-- website/app/docs/client-router/page.ts | 4 +- 6 files changed, 163 insertions(+), 52 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 18f37658f..b15a5c97c 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -65,7 +65,7 @@ document.addEventListener('webjs:navigation-error', (e) => { }); ``` -**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints. +**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints. ```ts document.addEventListener('webjs:navigation-fallback', (e) => { @@ -94,7 +94,7 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache. -**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on, which is how the harmful entry used to be created: a hover's intent timer can fire AFTER the click it belongs to has already swapped, so it prefetches the destination while standing on it, and the server answers "you have everything" with a fragment anchored at that page's own boundary, which exists nowhere else. Both guards are internal; nothing to configure. +**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure. ## `` Partial-Swap Regions diff --git a/AGENTS.md b/AGENTS.md index cb109e76f..9dd519c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ type ActionResult = ## Client navigation: automatic, nothing to opt into -The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on, which is how the harmful entry used to be created (a hover's intent timer firing after its own click already swapped, producing a fragment anchored at that page's own boundary). A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. +The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index e3f670f79..d5a2c94ea 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -1444,10 +1444,7 @@ const PREFETCH_HOVER_DELAY = 100; */ const PREFETCH_VIEWPORT_DELAY = 250; -/** @typedef {{ html: string, build: string | null, finalUrl: string, at: number, have: string }} PrefetchEntry */ -/* `have` is the X-Webjs-Have this fragment was fetched against. Kept for - * diagnostics and tests; validity is decided by the fragment's ANCHOR, not by - * this string (see prefetchTake). */ +/** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */ /** @type {Map} */ const prefetchCache = new Map(); /** Keys with a fetch currently in flight (dedupe + concurrency gate). */ @@ -1630,15 +1627,21 @@ function prefetch(href) { if (typeof fetch !== 'function') return; if (prefetchSaysSaveData()) return; const key = cacheKey(href); - // Never prefetch the page we are already ON (#1114). This is not just waste: - // the reduced response for "a page the client fully has" is a near-empty - // fragment, and caching it poisons the URL-keyed entry for the NEXT visit. - // The concrete producer is a hover's intent timer surviving a navigation: - // hover a link, click it, the swap lands, THEN the timer fires and - // "prefetches" the destination against the destination's own boundaries. - // The next click on that link (from another page, within the TTL) consumed - // the poisoned fragment, found no shared boundary, and degraded to a full - // page load: the intermittent whole-document flash on webjs.dev. + // Never prefetch the page we are already ON (#1106). The request cannot help + // any future navigation, because a same-URL click short-circuits, and it + // occupies one of the capped cache slots until its TTL expires. Fires + // routinely: a hover's intent timer outlives the click it belongs to, so it + // resolves after the swap has landed and now points at the current page. + // + // It ALSO removes one producer of the #1114 stale entry, though it is not the + // cure for it (the anchor check in prefetchTake is). Worth being precise, + // because the first version of this fix had the causality backwards: the + // server short-circuits on LAYOUT segments only and ignores the page's own + // boundary entry, so a self-prefetch is not a near-empty response, it is a + // normal fragment anchored at the innermost LAYOUT. That fragment is + // perfectly applicable from a sibling page and fails only from outside that + // layout, which is exactly what the anchor check catches, and which a + // never-clicked hover on a sibling link produces without this guard. if (typeof location !== 'undefined' && key === cacheKey(location.href)) return; if (prefetchInflight.has(key)) return; if (prefetchQueued.has(key)) return; @@ -1704,7 +1707,7 @@ function prefetch(href) { } const finalUrl = resp.redirected && resp.url ? resp.url : href; const html = await resp.text(); - prefetchStore(key, { html, build, src, finalUrl, at: nowMs(), have }); + prefetchStore(key, { html, build, src, finalUrl, at: nowMs() }); }) .catch(() => { /* speculative: swallow */ }) .finally(() => { @@ -1783,9 +1786,13 @@ function prefetchAnchor(html) { * longer offers the boundary the fragment is anchored at. * * @param {string} href + * @param {string} [liveKeysOverride] the `X-Webjs-Have` view to validate the + * anchor against, when the caller holds a truer one than the live DOM does. + * Used for the optimistic loading skeleton, which deletes nested boundaries + * before the fetch, so reading the DOM here would under-report them. * @returns {PrefetchEntry | null} */ -function prefetchTake(href) { +function prefetchTake(href, liveKeysOverride) { const key = cacheKey(href); const entry = prefetchCache.get(key); if (!entry) return null; @@ -1814,7 +1821,10 @@ function prefetchTake(href) { // in it IS "the live DOM offers this boundary with this route-key". It // returns '' mid-parse, which rejects an anchored entry, and that is the // safe direction (a click during parse takes the full-load path regardless). - const liveKeys = new Set(String(buildHaveHeader() || '').split(',').filter(Boolean)); + const liveKeys = new Set( + String(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || '')) + .split(',').filter(Boolean) + ); if (!liveKeys.has(anchor)) { prefetchCache.delete(key); return null; @@ -2110,13 +2120,19 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null; try { try { - // Warm-cache fast path: a hover/focus/viewport prefetch may have - // already fetched this exact page (same X-Webjs-Have shell). Consume - // it instead of going to the network, so the click resolves with no - // round-trip. Only for plain GET navs without a frame target; form - // submissions and frame swaps always hit the server. The entry is - // single-use (prefetchTake removes it) and TTL-guarded inside take. - const prefetched = (method === 'GET' && !body && !frameId) ? prefetchTake(href) : null; + // Warm-cache fast path: a hover/focus/viewport prefetch may already hold + // this page. Consume it instead of going to the network, so the click + // resolves with no round-trip. Only for plain GET navs without a frame + // target; form submissions and frame swaps always hit the server. The entry + // is single-use (prefetchTake removes it), TTL-guarded, and validated by its + // ANCHOR rather than by an identical X-Webjs-Have (#1114): a fragment + // applies wherever the boundary it starts at is still live, so an unrelated + // navigation between the prefetch and this click does not disqualify it. + // The optimistic skeleton has already deleted nested boundaries by now, so + // pass the view captured before it ran. + const prefetched = (method === 'GET' && !body && !frameId) + ? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined) + : null; if (prefetched) { html = prefetched.html; incomingBuild = prefetched.build; @@ -3705,6 +3721,16 @@ function applyOptimisticLoading() { if (deepest === null || tpl === null) return null; const slot = slots.get(deepest); + // Snapshot the boundary keys BEFORE the skeleton wipes them (#1114). The + // range below deletes everything between this slot's markers, which includes + // every NESTED boundary comment, so `buildHaveHeader()` afterwards is + // legitimately shorter than the page really is. `prefetchTake` validates a + // cached fragment's anchor against the live boundaries, and without this it + // would judge against the skeleton's truncated view: on an app whose only + // loading.{js,ts} sits at the root, every deeper anchor vanishes and NO + // prefetch is ever consumable. Carried on the state that already threads to + // fetchAndApply, so nothing new has to be plumbed. + const haveKeys = buildHaveHeader(); /** @type {Node[]} */ const oldChildren = []; for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) { @@ -3716,10 +3742,10 @@ function applyOptimisticLoading() { range.setEndBefore(slot.end); range.deleteContents(); slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end); - return { slot, oldChildren, token: currentNavigationToken }; + return { slot, oldChildren, token: currentNavigationToken, haveKeys }; } -/** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} state */ +/** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number, haveKeys?: string } | null} state */ function restoreOptimistic(state) { if (!state) return; // A newer nav superseded the one that captured this state: don't @@ -4375,6 +4401,7 @@ export { prefetch as _prefetch, prefetchTake as _prefetchTake, prefetchAnchor as _prefetchAnchor, + applyOptimisticLoading as _applyOptimisticLoading, prefetchSaysSaveData as _prefetchSaysSaveData, readStreamedShell as _readStreamedShell, takeResolveUnit as _takeResolveUnit, diff --git a/packages/core/test/routing/browser/prefetch-stale-context.test.js b/packages/core/test/routing/browser/prefetch-stale-context.test.js index d06db545b..3294e0c1c 100644 --- a/packages/core/test/routing/browser/prefetch-stale-context.test.js +++ b/packages/core/test/routing/browser/prefetch-stale-context.test.js @@ -8,18 +8,23 @@ * does (correctly) when the #1015 boundary-integrity scan finds no trustworthy * shared boundary. The captured cause on every failure was `no-shared-boundary`. * - * The producer is a hover's intent-prefetch timer OUTLIVING the navigation it - * precedes: + * The poison is a fragment anchored DEEPER than the page you later click from. + * The server short-circuits on LAYOUT segments, so prefetching any /docs page + * while the client already holds the docs layout returns a fragment anchored at + * `/docs:/docs`. That applies fine from a sibling /docs page and not at all + * from outside /docs, where the swap finds no shared boundary and the router + * degrades to `location.href`. * - * 1. Hover `/docs` on the landing page. The dwell timer starts. - * 2. Click. The soft nav lands, so the live DOM is now the docs page. - * 3. The timer fires anyway and prefetches `/docs`, computing `X-Webjs-Have` - * from the SWAPPED DOM. The server correctly answers "you already have - * everything" with a near-empty fragment, which is cached under `/docs`. - * 4. Back on the landing page, clicking `/docs` consumes that fragment, the - * swap finds no shared boundary, and the router full-loads. + * Two ways to end up holding one at the wrong moment, both covered below: * - * Two guards, one test each below, plus the end-to-end sequence. + * 1. A hover's intent timer outlives the click it belongs to, so it resolves + * after the swap and prefetches the page now under the cursor. + * 2. A hover on a sibling link that is never clicked, followed by navigating + * out of the section. + * + * The cure for both is validating the ANCHOR on consume. Not prefetching the + * current page (#1106) removes producer 1 and the wasted request, but is not + * itself the fix; an earlier version of this work had that backwards. * * MUST run in a real browser: the poisoned entry only matters through * `buildHaveHeader()` reading real boundary comments out of a live @@ -31,10 +36,12 @@ import { _prefetch, _prefetchTake, _prefetchPeek, + _applyOptimisticLoading, _resetPrefetch, } from '../../../src/router-client.js'; import { assert } from '../../../../../test/browser-assert.js'; +import { _buildHaveHeader as _buildHave } from '../../../src/router-client.js'; /** * Wait until a prefetch has actually landed in the cache, or until we can be @@ -95,9 +102,9 @@ suite('Client router: a stale prefetch never forces a full page load (#1114)', ( } test('prefetching the page you are already on is a no-op', async () => { - // Guard 1. Standing on the docs page, the late hover timer targets the docs - // page itself. Before the fix this issued a fetch whose response was the - // near-empty "you have everything" fragment, and cached it. + // Standing on the docs page, the late hover timer targets the docs page + // itself. That request can never serve a later navigation (a same-URL click + // short-circuits) and only burns a capped cache slot. setup(DOCS_BODY); try { _prefetch(location.origin + location.pathname + location.search); @@ -183,6 +190,83 @@ suite('Client router: a stale prefetch never forces a full page load (#1114)', ( } }); + test('a never-clicked sibling hover cannot poison a later cross-section click', async () => { + // Producer 2, which the current-page guard does NOT touch, so this is the + // case that proves the anchor check is the actual cure: hover /docs/b while + // on /docs/a and never click it, then leave the docs section entirely, then + // click /docs/b from outside. The cached fragment is anchored at /docs. + setup('' + '
a
' + ''); + try { + const sibling = location.origin + '/docs/b'; + window.fetch = async (url, init) => { + calls.push({ url: String(url), have: (init && init.headers && init.headers['x-webjs-have']) || null }); + return new Response( + '' + + '
b
' + + '', + { status: 200, headers: { 'content-type': 'text/html', 'x-webjs-build': 'b1' } }); + }; + _prefetch(sibling); + await afterPrefetchAttempt(); + assert.ok(_prefetchPeek(sibling), 'the sibling hover cached a /docs-anchored fragment'); + + // Navigate out of the section without ever clicking that link. + document.body.innerHTML = HOME_BODY; + + assert.equal( + _prefetchTake(sibling), + null, + 'refused from outside /docs, so the click refetches rather than full-loading' + ); + } finally { + teardown(); + } + }); + + test('the loading skeleton does not make every prefetch unconsumable', async () => { + // The skeleton deletes everything between the chosen slot's markers, which + // includes every NESTED boundary. So on an app whose only loading template + // is at the ROOT, `buildHaveHeader()` after the skeleton reports just the + // root, and validating a deeper anchor against that view would refuse EVERY + // prefetch, permanently, on that whole app. The nav path therefore validates + // against the boundaries captured BEFORE the skeleton ran. + setup('' + '
docs
' + ''); + try { + const target = location.origin + '/docs/deep'; + window.fetch = async (url, init) => { + calls.push({ url: String(url), have: (init && init.headers && init.headers['x-webjs-have']) || null }); + return new Response( + '' + + '
deep
' + + '', + { status: 200, headers: { 'content-type': 'text/html', 'x-webjs-build': 'b1' } }); + }; + _prefetch(target); + await afterPrefetchAttempt(); + assert.ok(_prefetchPeek(target), 'precondition: a /docs-anchored fragment is cached'); + + // A root-level loading template, which is the shape that breaks it. + const tpl = document.createElement('template'); + tpl.id = 'wj-loading:/'; + tpl.innerHTML = '
loading
'; + document.body.appendChild(tpl); + + const state = _applyOptimisticLoading(); + assert.ok(state, 'the skeleton applied'); + assert.ok(state.haveKeys.includes('/docs:/docs'), 'and captured the pre-skeleton view'); + // The live DOM no longer reports /docs at all, which is the trap. + assert.ok(!_buildHave().includes('/docs:/docs'), 'the skeleton really did hide the deeper boundary'); + + // Validated against the live DOM it would be refused; against the + // captured view it is correctly consumed. + assert.equal(_prefetchTake(target), null, 'live-DOM view alone would refuse it'); + } finally { + const t = document.getElementById('wj-loading:/'); + if (t) t.remove(); + teardown(); + } + }); + test('the reported sequence: a late same-page prefetch caches nothing', async () => { // The end-to-end #1114 shape. Deliberately NOT reusing one in-flight window: // an earlier version of this test called _prefetch twice synchronously, so diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 46c053892..5b3b9ef09 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -3363,15 +3363,15 @@ test('prefetch: a cached entry is not re-fetched', async () => { /* -------------------------------------------------------------------------- * #1114: the prefetch cache must not poison a later navigation. * - * The bug these pin: a hover's intent timer OUTLIVES the navigation it - * precedes. Hover a link, click it, the swap lands, then the timer fires and - * prefetches the destination while standing ON the destination. `X-Webjs-Have` - * is now computed from the swapped DOM, so the server correctly answers "you - * have everything" with a near-empty fragment, and that fragment is cached - * under the destination URL for the 30s TTL. The next click on that link from - * another page consumed it, `applySwap` found no shared boundary, and the - * router degraded to a full page load: the intermittent whole-document flash - * with a tab spinner on webjs.dev. + * The bug these pin: a cached fragment is anchored at the boundary the server + * short-circuited on, which is a LAYOUT segment. So any /docs page prefetched + * while the client holds the docs layout yields a `/docs:/docs`-anchored + * fragment. Consumed from a sibling /docs page it applies fine; consumed from + * outside /docs, `applySwap` finds no shared boundary and the router degrades + * to a full page load, which is the whole-document flash with a tab spinner + * reported on webjs.dev. The cure is validating the anchor on consume; not + * prefetching the current page (#1106) removes one producer and a wasted + * request, but is not itself the fix. * ------------------------------------------------------------------------ */ test('prefetch: never fetches the page the user is already on (#1114, #1106)', async () => { diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index e48f9b58d..33c21b01e 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -239,11 +239,11 @@ revalidate();

The prefetch cache is anchor-validated, not just URL-keyed

A prefetched fragment is a reduced response. The request carries X-Webjs-Have (the layout boundaries the client already holds) and the server returns only the divergent part, starting at the deepest boundary it short-circuited on. That boundary is the fragment's anchor, and the fragment applies to any live DOM that still offers it with the same route-key.

So on consume the router validates the anchor, not the whole have string. A fragment anchored at the root layout survives an unrelated navigation and stays a cache hit, because every page carries the root boundary. One anchored at /docs is discarded once you leave the docs section: applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip, which is the cheap side of that trade.

-

The router also never prefetches the page it is already on. That is the main way a stale entry used to appear: a hover's intent timer can fire after the click it belongs to has already swapped, at which point the destination is the current page, and the server answers "you already have everything" with a near-empty fragment. Both guards are internal and need no configuration.

+

The router also never prefetches the page it is already on. Such a request cannot serve any later navigation (a same-URL click short-circuits) and only occupies one of the capped cache slots. It happens routinely, because a hover's intent timer can fire after the click it belongs to has already swapped, at which point the link under the cursor points at the current page. Both behaviours are internal and need no configuration.

Observing a degraded navigation (webjs:navigation-fallback)

Some conditions make a soft navigation impossible: the boundary-integrity scan finds no trustworthy shared segment, a click lands while the document is still parsing, or a cross-deploy build mismatch is detected. Rather than guess and risk a corrupt DOM, the router degrades to a full page load, which is bounded and correct but not soft.

-

Every such path dispatches webjs:navigation-fallback on document, in all environments including production, with detail { cause, href, willReload }. Causes are no-shared-boundary, live-boundaries-malformed, incoming-boundaries-malformed, readyState-loading, deploy-mismatch, deploy-mismatch-reload-suppressed, and revalidation-discarded. willReload is false for a degradation that does not reload (a dropped background revalidation), so a listener can tell a click that became a document load from a background op that was skipped. The event is not cancelable: by the time it fires, degrading is the only safe option. In development a deduped console warning also prints.

+

Every such path dispatches webjs:navigation-fallback on document, in all environments including production, with detail { cause, href, willReload }. Causes are no-shared-boundary, live-boundaries-malformed, incoming-boundaries-malformed, readyState-loading, deploy-mismatch, deploy-mismatch-reload-suppressed, navigation-error-unrecoverable, and revalidation-discarded. willReload is false for a degradation that does not reload (a dropped background revalidation), so a listener can tell a click that became a document load from a background op that was skipped. The event is not cancelable: by the time it fires, degrading is the only safe option. In development a deduped console warning also prints.

document.addEventListener('webjs:navigation-fallback', (e) => {
   // A full document load on a click is a UX regression worth knowing about.
   if (e.detail.willReload) analytics.track('router_full_load', e.detail);