diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 7d67033a0..b15a5c97c 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`, `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) => { + // 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 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 `` 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..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. 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 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 13df132bc..d5a2c94ea 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -764,19 +764,58 @@ 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; + // 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.` ); } @@ -1137,7 +1176,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; } @@ -1588,6 +1627,22 @@ function prefetch(href) { if (typeof fetch !== 'function') return; if (prefetchSaysSaveData()) return; const key = cacheKey(href); + // 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; const existing = prefetchCache.get(key); @@ -1699,20 +1754,83 @@ 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 + * @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; + 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(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || '')) + .split(',').filter(Boolean) + ); + if (!liveKeys.has(anchor)) { + prefetchCache.delete(key); + return null; + } + } prefetchCache.delete(key); - if ((nowMs() - entry.at) >= PREFETCH_TTL) return null; return entry; } @@ -1944,7 +2062,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; + } } /** @@ -1994,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; @@ -2753,14 +2885,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 +3031,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 +3044,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'; } @@ -3586,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) { @@ -3597,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 @@ -4255,6 +4400,8 @@ export { prefetchHasHoverPointer as _prefetchHasHoverPointer, 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 new file mode 100644 index 000000000..3294e0c1c --- /dev/null +++ b/packages/core/test/routing/browser/prefetch-stale-context.test.js @@ -0,0 +1,295 @@ +/** + * 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 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`. + * + * Two ways to end up holding one at the wrong moment, both covered below: + * + * 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 + * `document.body`, and the failure mode is `location.href`, which linkedom does + * not model. + */ +import { + enableClientRouter, + _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 + * 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 () => { + // 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); + 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('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 + '/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(); + assert.ok(_prefetchPeek(target), 'precondition: cached'); + + // Leave the docs section entirely: no /docs boundary remains. + document.body.innerHTML = HOME_BODY; + + assert.equal( + _prefetchTake(target), + null, + '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, not left to poison the next click'); + } finally { + teardown(); + } + }); + + 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 + // 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 here = location.origin + location.pathname + location.search; + assert.equal(calls.length, 0, 'clean slate'); + + // The hover timer fires while standing on the page it points at. + _prefetch(here); + await afterPrefetchAttempt(200); + + 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(); + } + }); + +}); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 5346af324..5b3b9ef09 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -32,7 +32,8 @@ 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, _applyStreamedResolve, @@ -108,6 +109,8 @@ before(async () => { _prefetchHasHoverPointer, _prefetch, _prefetchTake, + _prefetchAnchor, + _buildHaveHeader, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, @@ -3357,6 +3360,102 @@ 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 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 () => { + 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('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( + _prefetchAnchor('
x
'), + '/docs:/docs' + ); + assert.equal( + _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: keeps an entry whose anchor is still live, drops one whose anchor is gone (#1114)', async () => { + await withPrefetchEnv(async () => { + // 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 = ''; + }); +}); + test('prefetch: skips non-HTML and error responses', async () => { await withPrefetchEnv(async () => { _prefetch('http://localhost/json'); @@ -3767,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 48e1a11db..33c21b01e 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -236,6 +236,19 @@ 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 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. 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, 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);
+});
+

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.