From 92608664b3b38a87249a50d360340258b1b15701 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 15:01:10 +0530 Subject: [PATCH 1/7] feat: device-adaptive link prefetch, viewport-safe on mobile The client router's default prefetch was always 'intent' (hover/focus/ touch-start). On touch there is no hover and touchstart fires at tap time, so the prefetch raced the navigation and the fast-by-default promise did not hold on mobile. Make the default device-adaptive: 'intent' on a hover-capable fine pointer, 'viewport' on touch (matchMedia('(hover: hover) and (pointer: fine)'), not a UA sniff). A per-link data-prefetch always overrides. Guard the viewport path against over-fetch the way Astro/Next/Nuxt/ Remix/TanStack/Turbo all do: a 250ms dwell before a viewport link warms, cancelled on scroll-out, so a fast scroll through a long list spends no requests. Keep touchstart as an extra warm for the tapped link. Broaden the connection gate to skip 2g effectiveType, not just Save-Data. --- packages/core/src/router-client.js | 134 +++++++++++++++--- .../core/test/routing/router-client.test.js | 74 +++++++++- 2 files changed, 185 insertions(+), 23 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 12881566a..acef853a2 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -220,6 +220,7 @@ export function disableClientRouter() { document.removeEventListener('pointerout', onPrefetchOut, true); document.removeEventListener('webjs:navigate', refreshPrefetchObservers); clearPrefetchHover(); + clearPrefetchViewTimers(); if (prefetchViewObserver) { prefetchViewObserver.disconnect(); prefetchViewObserver = null; } if (typeof history !== 'undefined' && prevScrollRestoration !== null) { history.scrollRestoration = prevScrollRestoration; @@ -960,12 +961,16 @@ function buildHaveHeader() { * warms a dedicated cache speculatively so the click reads it instantly. * * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*, - * like SvelteKit / Astro), defaulting to `intent` so the common case is - * fast without per-link opt-in, the way Next / Nuxt / SvelteKit ship - * auto-prefetch. Value vocabulary borrows Next's true/false/auto aliases: - * - intent (default) : hover / focus / touch, after a short dwell + * like SvelteKit / Astro). The default is DEVICE-ADAPTIVE so the common case + * is fast on every device without per-link opt-in: `intent` on a hover-capable + * pointer (a real head-start before the click), `viewport` on touch (no hover + * exists, and `touchstart` fires too close to the tap to front-run it). Value + * vocabulary borrows Next's true/false/auto aliases: + * - absent (default) : intent on pointer, viewport on touch (adaptive) + * - intent : hover / focus / touch, after a short dwell * - true / render : eager, as soon as a document scan sees it - * - auto / viewport : on viewport entry (IntersectionObserver, 0.5) + * - auto / viewport : on viewport entry (IntersectionObserver, 0.5), + * after a dwell so a fast scroll-through skips it * - false / none : never (also data-no-prefetch / rel="external") * * Why a separate cache, not snapshotCache: snapshotCache is keyed to the @@ -976,7 +981,10 @@ function buildHaveHeader() { * prefetchTake() before falling back to the network. * * Only same-origin in-app links are prefetched (the same eligibility as - * a click), and never under Save-Data / prefers-reduced-data. There is no + * a click), and never under Save-Data / prefers-reduced-data / a 2g link, + * never past a small concurrency cap, and never twice (deduped + cached). The + * viewport path additionally waits a dwell and cancels on scroll-out, so a + * fast scroll through a long list does not flood the network tab. There is no * logout-style heuristic: prefetch issues a real GET, so as everywhere in * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a * POST or a `
`, and `data-no-prefetch` / `rel="external"` opt out. @@ -996,6 +1004,13 @@ const PREFETCH_CONCURRENCY = 3; const PREFETCH_QUEUE_CAP = 24; /** Hover dwell before a prefetch fires (ms): filter drive-by pointer moves. Matches Remix's intent timeout. */ const PREFETCH_HOVER_DELAY = 100; +/** + * Viewport dwell before a prefetch fires (ms): a link must SETTLE on-screen, + * not merely flash past during a scroll. A fast scroll-through clears the + * timer on exit, so flicked-past links never fetch. Astro uses 300ms for the + * same purpose; we sit a touch lower so a deliberate stop still feels instant. + */ +const PREFETCH_VIEWPORT_DELAY = 250; /** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */ /** @type {Map} */ @@ -1011,18 +1026,28 @@ let prefetchHoverTimer = null; let prefetchHoverAnchor = null; /** IntersectionObserver for data-prefetch="viewport" anchors, or null. */ let prefetchViewObserver = null; +/** Per-anchor viewport-dwell timers, so a scroll-out can cancel before firing. */ +let prefetchViewTimers = new WeakMap(); +/** Live viewport-dwell timer ids, for bulk teardown on disable. */ +const prefetchViewPending = new Set(); /** - * True when the user or platform has asked us to conserve data. Both the - * Save-Data client hint and the prefers-reduced-data media query disable - * speculative fetching. Guarded for non-browser / partial DOM. + * True when the user or platform has asked us to conserve data, OR the + * connection is too slow to spend bytes speculatively. The Save-Data client + * hint, the prefers-reduced-data media query, and a 2g `effectiveType` all + * disable speculative fetching, the same gate Astro / Nuxt apply. Guarded for + * non-browser / partial DOM. * * @returns {boolean} */ function prefetchSaysSaveData() { try { const c = typeof navigator !== 'undefined' ? /** @type any */ (navigator).connection : null; - if (c && c.saveData === true) return true; + if (c) { + if (c.saveData === true) return true; + // effectiveType is 'slow-2g' | '2g' | '3g' | '4g'; skip the 2g tiers. + if (typeof c.effectiveType === 'string' && /2g$/.test(c.effectiveType)) return true; + } if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-data: reduce)').matches) { return true; } @@ -1030,6 +1055,27 @@ function prefetchSaysSaveData() { return false; } +/** + * Whether the device drives a hover-capable fine pointer (a mouse or + * trackpad), as opposed to touch. This picks the ADAPTIVE prefetch default: + * `intent` (hover / focus) on a pointer device, `viewport` on touch, since a + * touch device has no hover and `touchstart` fires too close to the tap to + * front-run it. Detected with `matchMedia('(hover: hover) and (pointer: fine)')` + * rather than a user-agent sniff. When `matchMedia` is unavailable we assume a + * pointer (the historical default), so a non-browser / partial-DOM environment + * keeps the `intent` behaviour and never silently switches to viewport. + * + * @returns {boolean} + */ +function prefetchHasHoverPointer() { + try { + if (typeof matchMedia === 'function') { + return matchMedia('(hover: hover) and (pointer: fine)').matches; + } + } catch { /* ignore */ } + return true; +} + /** * Lowercased whitespace-separated rel tokens of an anchor. * @param {Element} anchor @@ -1091,12 +1137,21 @@ function prefetchSuppressed(anchor) { * absent or unrecognised. * * Value mapping (case-insensitive): - * - absent / unknown : `intent` (the default) + * - absent / unknown : the DEVICE-ADAPTIVE default (intent on a pointer, + * viewport on touch); an explicit value always wins * - `intent` : hover / focus / touch, after a short dwell * - `true` / `render` : eager, as soon as a document scan sees the link - * - `auto` / `viewport`: on viewport entry (IntersectionObserver) + * - `auto` / `viewport`: on viewport entry (IntersectionObserver), after a dwell * - `false` / `none` : never (also via data-no-prefetch / rel="external") * + * The default is adaptive (not a single `intent`) because `intent` does not + * help on mobile: a touch device has no hover, and `touchstart` fires at tap + * time, so the prefetch races the navigation. On touch we default to + * `viewport` (warm links as they settle on-screen) and keep `touchstart` as an + * extra warm for the tapped link; on a pointer device `intent` stays the + * default (precise, cheap, a real head-start before the click). A per-link + * `data-prefetch` always overrides the adaptive default. + * * Returns `none` for suppressed anchors so callers have a single check. * * @param {Element} anchor @@ -1118,8 +1173,8 @@ function prefetchMode(anchor) { case 'intent': return 'intent'; default: - // Unset or unrecognised value: the fast default. - return 'intent'; + // Unset or unrecognised value: the device-adaptive default. + return prefetchHasHoverPointer() ? 'intent' : 'viewport'; } } @@ -1245,12 +1300,19 @@ function onPrefetchIntent(e) { if (!enabled) return; const anchor = closestAnchor(/** @type any */ (e.target)); if (!anchor) return; - // Only `intent` links prefetch on hover/focus/touch. `render` and - // `viewport` links are handled by the document scan / observer, and - // `none` is suppressed. - if (prefetchMode(anchor) !== 'intent') return; + const mode = prefetchMode(anchor); + // `none` is suppressed; `render` already prefetched on the document scan. + if (mode === 'none' || mode === 'render') return; const href = eligibleAnchorHref(anchor); if (!href) return; + // touchstart IS the tap: warm the tapped link immediately, for both intent + // and viewport modes (a single request for a link about to be navigated, the + // small mobile win the viewport default cannot give for the link just tapped). + // No dwell, since the tap is the intent. + if (e.type === 'touchstart') { prefetch(href); return; } + // hover / focus only warm `intent` links; `viewport` links are the + // observer's job (warmed on a dwell, not on a stray hover). + if (mode !== 'intent') return; // pointerover/focusin bubble, so re-entering a child of the same anchor // would re-arm; collapse to one timer per anchor. if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return; @@ -1274,6 +1336,13 @@ function clearPrefetchHover() { prefetchHoverAnchor = null; } +/** Cancel every pending viewport-dwell timer and reset the per-anchor map. */ +function clearPrefetchViewTimers() { + for (const timer of prefetchViewPending) clearTimeout(timer); + prefetchViewPending.clear(); + prefetchViewTimers = new WeakMap(); +} + /** * Nearest enclosing , crossing shadow boundaries, from an event * target. composedPath is click-only, so walk getRootNode().host here. @@ -1314,11 +1383,30 @@ function refreshPrefetchObservers() { if (!prefetchViewObserver) { prefetchViewObserver = new IntersectionObserver((entries) => { for (const entry of entries) { - if (!entry.isIntersecting) continue; const anchor = /** @type {Element} */ (entry.target); - prefetchViewObserver.unobserve(anchor); - const href = eligibleAnchorHref(anchor); - if (href && prefetchMode(anchor) === 'viewport') prefetch(href); + if (entry.isIntersecting) { + // Arm a dwell timer; the link must STAY on-screen to warm. One + // timer per anchor, so re-entry while pending does not stack. + if (prefetchViewTimers.has(anchor)) continue; + const timer = setTimeout(() => { + prefetchViewPending.delete(timer); + prefetchViewTimers.delete(anchor); + prefetchViewObserver.unobserve(anchor); + const href = eligibleAnchorHref(anchor); + if (href && prefetchMode(anchor) === 'viewport') prefetch(href); + }, PREFETCH_VIEWPORT_DELAY); + prefetchViewTimers.set(anchor, timer); + prefetchViewPending.add(timer); + } else { + // Scrolled out before the dwell elapsed: cancel, so a fast + // scroll-through never spends a request. + const timer = prefetchViewTimers.get(anchor); + if (timer) { + clearTimeout(timer); + prefetchViewPending.delete(timer); + prefetchViewTimers.delete(anchor); + } + } } }, { threshold: 0.5 }); } else { @@ -3070,6 +3158,7 @@ export { regraftPermanentInSlice as _regraftPermanentInSlice, prefetchSuppressed as _prefetchSuppressed, prefetchMode as _prefetchMode, + prefetchHasHoverPointer as _prefetchHasHoverPointer, prefetch as _prefetch, prefetchTake as _prefetchTake, prefetchSaysSaveData as _prefetchSaysSaveData, @@ -3090,6 +3179,7 @@ export function _resetPrefetch() { prefetchQueue.length = 0; prefetchQueued.clear(); clearPrefetchHover(); + clearPrefetchViewTimers(); } /** Test-only: read the monotonic navigation-token counter. */ diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 498b2c031..a383d80a5 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -31,7 +31,7 @@ let _collect, _longest, _keyOf, _diffEl, _reconcile, _onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, _restoreOptimistic, _navToken, _bumpNavToken, _currentPageUrl, _setCurrentPageUrl, - _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetch, _prefetchTake, + _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch, _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, enableClientRouter, disableClientRouter, revalidate, @@ -100,6 +100,7 @@ before(async () => { _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, + _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchSaysSaveData, @@ -2487,6 +2488,18 @@ function mkAnchor(href, attrs = {}) { return a; } +/** Run `fn` with a stubbed matchMedia answering `map[query]`, then restore. */ +function withMatchMedia(map, fn) { + const orig = globalThis.matchMedia; + globalThis.matchMedia = /** @type any */ ((q) => ({ matches: !!map[q], media: q })); + try { + return fn(); + } finally { + if (orig === undefined) delete globalThis.matchMedia; + else globalThis.matchMedia = orig; + } +} + /** Install a fake same-origin location + a recording fetch. */ function withPrefetchEnv(run, { fetchImpl, navigator: nav } = {}) { const origLoc = globalThis.location; @@ -2583,6 +2596,45 @@ test('prefetchMode: suppression wins over data-prefetch', async () => { }); }); +test('prefetchMode: the default is device-adaptive (intent on pointer, viewport on touch)', async () => { + await withPrefetchEnv(() => { + // A hover-capable fine pointer (mouse / trackpad): intent is the default. + withMatchMedia({ '(hover: hover) and (pointer: fine)': true }, () => { + assert.equal(_prefetchHasHoverPointer(), true); + assert.equal(_prefetchMode(mkAnchor('/a')), 'intent', 'pointer default is intent'); + assert.equal(_prefetchMode(mkAnchor('/a', { 'data-prefetch': 'bogus' })), 'intent'); + }); + // Touch (no hover, coarse pointer): viewport becomes the default. + withMatchMedia({ '(hover: hover) and (pointer: fine)': false }, () => { + assert.equal(_prefetchHasHoverPointer(), false); + assert.equal(_prefetchMode(mkAnchor('/a')), 'viewport', 'touch default is viewport'); + assert.equal(_prefetchMode(mkAnchor('/a', { 'data-prefetch': 'bogus' })), 'viewport'); + }); + // A per-link data-prefetch ALWAYS overrides the adaptive default, even on touch. + withMatchMedia({ '(hover: hover) and (pointer: fine)': false }, () => { + assert.equal(_prefetchMode(mkAnchor('/a', { 'data-prefetch': 'intent' })), 'intent', 'explicit intent wins on touch'); + assert.equal(_prefetchMode(mkAnchor('/a', { 'data-prefetch': 'none' })), 'none'); + assert.equal(_prefetchMode(mkAnchor('/a', { 'data-prefetch': 'render' })), 'render'); + }); + }); +}); + +test('prefetchHasHoverPointer: assumes a pointer when matchMedia is unavailable', async () => { + await withPrefetchEnv(() => { + const orig = globalThis.matchMedia; + // @ts-ignore deliberately remove matchMedia to exercise the fallback. + delete globalThis.matchMedia; + try { + // No matchMedia (non-browser / partial DOM): keep the historical intent + // default rather than silently switching to viewport. + assert.equal(_prefetchHasHoverPointer(), true); + assert.equal(_prefetchMode(mkAnchor('/a')), 'intent'); + } finally { + if (orig !== undefined) globalThis.matchMedia = orig; + } + }); +}); + test('prefetch: warms the cache with the server fragment', async () => { await withPrefetchEnv(async (calls) => { _prefetch('http://localhost/about'); @@ -2691,6 +2743,26 @@ test('prefetch: respects Save-Data (no fetch)', async () => { }, { navigator: { connection: { saveData: true } } }); }); +test('prefetch: respects a 2g effectiveType (no fetch)', async () => { + await withPrefetchEnv(async (calls) => { + assert.equal(_prefetchSaysSaveData(), true, 'slow-2g detected as a throttled link'); + _prefetch('http://localhost/slow'); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 0, 'no fetch on a 2g link'); + }, { navigator: { connection: { effectiveType: 'slow-2g' } } }); +}); + +test('prefetch: a fast effectiveType does NOT suppress (4g still warms)', async () => { + // Counterfactual for the 2g gate: only the 2g tiers are throttled, so a 4g + // link must still prefetch (otherwise the gate would kill all speculation). + await withPrefetchEnv(async (calls) => { + assert.equal(_prefetchSaysSaveData(), false, '4g is not a throttled link'); + _prefetch('http://localhost/fast'); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 1, 'fetch issued on 4g'); + }, { navigator: { connection: { effectiveType: '4g' } } }); +}); + test('prefetchTake: consumes a cached entry exactly once', async () => { await withPrefetchEnv(async () => { _prefetch('http://localhost/take'); From 6622b635a34f07ed6793894bc1d868015b40f93e Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 15:04:39 +0530 Subject: [PATCH 2/7] test: browser coverage for the viewport prefetch dwell + cancel gate Drive a stubbed IntersectionObserver to prove the over-fetch gate in a real browser: a viewport link warms only after a dwell, a fast scroll-through (enter then exit before the dwell) spends no request, and a cancelled anchor can still warm on a later settled re-entry. --- .../routing/browser/prefetch-viewport.test.js | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/core/test/routing/browser/prefetch-viewport.test.js diff --git a/packages/core/test/routing/browser/prefetch-viewport.test.js b/packages/core/test/routing/browser/prefetch-viewport.test.js new file mode 100644 index 000000000..6cfa8e32c --- /dev/null +++ b/packages/core/test/routing/browser/prefetch-viewport.test.js @@ -0,0 +1,140 @@ +/** + * Real-browser tests for the viewport-prefetch over-fetch gate (#530). + * + * The default link prefetch is device-adaptive: `viewport` on touch. To keep + * that from flooding the network tab on a long, fast-scrolled list, a viewport + * link must DWELL on-screen before it warms, and the pending warm is cancelled + * the moment the link scrolls back out. This is the same gate Astro / Next / + * Nuxt / Remix / TanStack / Turbo all ship. + * + * These run in a real browser because the gate lives on an IntersectionObserver + * + a dwell timer, neither of which linkedom drives. We stub IntersectionObserver + * so the test drives intersection in/out explicitly (the deterministic pattern + * the lazy-frame test uses), recreate the router observer so it picks up the + * stub, and let the real dwell-timer logic run on the wall clock. + */ +import { enableClientRouter, disableClientRouter } from '../../../src/router-client.js'; + +const assert = { + ok: (v, msg) => { if (!v) throw new Error(msg || `Expected truthy, got ${v}`); }, + equal: (a, b, msg) => { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); }, +}; +const tick = () => new Promise((r) => setTimeout(r, 0)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function settle() { for (let i = 0; i < 4; i++) await tick(); } + +// Comfortably past the 250ms viewport dwell, so a warm that was going to fire +// has fired by the time we assert. +const PAST_DWELL = 360; + +suite('Client router: viewport prefetch over-fetch gate (#530)', () => { + let container, origFetch, origIO, calls, ioInstances; + + /** Drive the router observer with a controllable IntersectionObserver. */ + function setup() { + origIO = window.IntersectionObserver; + ioInstances = []; + window.IntersectionObserver = class { + constructor(cb) { this.cb = cb; this.observed = new Set(); ioInstances.push(this); } + observe(el) { this.observed.add(el); } + unobserve(el) { this.observed.delete(el); } + disconnect() { this.observed.clear(); } + /** Test helper: deliver an intersection entry to the router callback. */ + emit(el, isIntersecting) { this.cb([{ target: el, isIntersecting }], this); } + }; + // Drop any real observer the router already created, then re-enable so the + // prefetch observer is rebuilt from the stub above. + disableClientRouter(); + enableClientRouter(); + + container = document.createElement('div'); + document.body.appendChild(container); + origFetch = window.fetch; + calls = []; + window.fetch = (url, init) => { + calls.push({ url: String(url), init: init || {} }); + return Promise.resolve(new Response( + '

ok

', + { status: 200, headers: { 'content-type': 'text/html', 'x-webjs-build': '' } }, + )); + }; + } + + function teardown() { + window.fetch = origFetch; + window.IntersectionObserver = origIO; + container.remove(); + disableClientRouter(); + enableClientRouter(); + } + + /** The router prefetch observer (the only IO instance built on enable). */ + function viewObserver() { return ioInstances[ioInstances.length - 1]; } + + /** Add a viewport-mode anchor and make the router observe it. */ + function addViewportAnchor(href) { + const a = document.createElement('a'); + a.setAttribute('href', href); + a.setAttribute('data-prefetch', 'viewport'); + container.appendChild(a); + // A re-scan (what a soft nav fires) makes refreshPrefetchObservers observe + // the new anchor through the stub. + document.dispatchEvent(new Event('webjs:navigate')); + return a; + } + + test('a viewport link warms only AFTER it dwells on-screen', async () => { + setup(); + try { + const a = addViewportAnchor('/dwell-target'); + await settle(); + const obs = viewObserver(); + assert.ok(obs.observed.has(a), 'the viewport anchor is observed'); + + obs.emit(a, true); // enters the viewport: arm the dwell timer + await sleep(60); + // The whole point of the gate: nothing is fetched during the dwell. + assert.equal(calls.length, 0, 'no request while the link is still dwelling'); + + await sleep(PAST_DWELL); + assert.equal(calls.length, 1, 'the link warms once the dwell elapses'); + assert.ok(calls[0].url.includes('/dwell-target'), 'it warmed the right URL'); + assert.equal(calls[0].init.headers['x-webjs-prefetch'], '1', 'tagged as a prefetch'); + } finally { teardown(); } + }); + + test('a fast scroll-through never spends a request (cancel on exit)', async () => { + setup(); + try { + const a = addViewportAnchor('/flick-target'); + await settle(); + const obs = viewObserver(); + + obs.emit(a, true); // scrolls in: arm the dwell timer + await sleep(40); // ...but well under the 250ms dwell + obs.emit(a, false); // scrolls back out before it fires: cancel + + await sleep(PAST_DWELL); + assert.equal(calls.length, 0, 'a link flicked past the viewport is never fetched'); + } finally { teardown(); } + }); + + test('re-entering after a cancel can still warm (the anchor is not poisoned)', async () => { + setup(); + try { + const a = addViewportAnchor('/re-enter-target'); + await settle(); + const obs = viewObserver(); + + obs.emit(a, true); + await sleep(40); + obs.emit(a, false); // cancel + await sleep(40); + + obs.emit(a, true); // user scrolls back and settles this time + await sleep(PAST_DWELL); + assert.equal(calls.length, 1, 'a settled re-entry warms exactly once'); + assert.ok(calls[0].url.includes('/re-enter-target')); + } finally { teardown(); } + }); +}); From 78b559d4dc1934c481c2d8923e79a511ff9f8369 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 15:06:42 +0530 Subject: [PATCH 3/7] docs: document the device-adaptive prefetch default + over-fetch gate Update agent-docs/advanced.md, the docs-site client-router page, and the root AGENTS.md client-router summary: the default is now intent on a hover pointer and viewport on touch, the viewport path is dwell-gated and cancels on scroll-out, and the connection gate also skips 2g. --- AGENTS.md | 2 +- agent-docs/advanced.md | 41 ++++++++++++++++++++--------- docs/app/docs/client-router/page.ts | 11 +++++--- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd8feb4af..c651daf9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -327,7 +327,7 @@ type ActionResult = Nested layouts auto-emit `` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); 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 advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, `intent` strategy, per-link `data-prefetch`), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), and **stream actions** (`` element-level updates, #248). 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). +The advanced client-router surface is in `agent-docs/advanced.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), and **stream actions** (`` element-level updates, #248). 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/agent-docs/advanced.md b/agent-docs/advanced.md index a9fc1fca1..f4f4033b7 100644 --- a/agent-docs/advanced.md +++ b/agent-docs/advanced.md @@ -570,20 +570,30 @@ affects a cached page. ### Link prefetch Same-origin in-app links are prefetched speculatively so a click -resolves from a warm cache with no round-trip. On by default with the -`intent` strategy (no per-link opt-in needed), the way Next / Nuxt / -SvelteKit ship auto-prefetch. The prefetch request carries the same -`X-Webjs-Have` header a real navigation sends, so the server returns the -same divergent fragment; that fragment lands in a dedicated prefetch -cache (separate from the back/forward snapshot cache) and `fetchAndApply` -consumes it via `prefetchTake` before falling back to the network. +resolves from a warm cache with no round-trip. On by default (no per-link +opt-in needed), the way Next / Nuxt / SvelteKit ship auto-prefetch. The +prefetch request carries the same `X-Webjs-Have` header a real navigation +sends, so the server returns the same divergent fragment; that fragment +lands in a dedicated prefetch cache (separate from the back/forward +snapshot cache) and `fetchAndApply` consumes it via `prefetchTake` before +falling back to the network. + +The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve +both input modalities. On a hover-capable fine pointer (mouse / trackpad) +the default is `intent` (warm on hover / focus, a real head-start before +the click). On touch the default is `viewport` (warm as links settle +on-screen), because a touch device has no hover and `touchstart` fires at +tap time, too late to front-run the navigation. The modality is detected +with `matchMedia('(hover: hover) and (pointer: fine)')`, not a user-agent +sniff. A per-link `data-prefetch` always overrides the adaptive default. Per link, set `data-prefetch` (a valid-HTML `data-*` attribute, the shape SvelteKit and Astro use; Next / Nuxt / Remix use a component prop, which webjs has no equivalent for since links are plain `
`): ```html -intent: hover / focus / touch (default) +adaptive: intent on pointer, viewport on touch (default) +hover / focus / touch eager on insert on scroll-into-view never @@ -591,9 +601,14 @@ webjs has no equivalent for since links are plain ``): Next-style aliases are accepted: `true` = `render`, `auto` = `viewport`, `false` = `none`. `intent` waits a short dwell (~100ms) after hover/focus -so a pointer passing over a link does not fetch it; `viewport` uses an -IntersectionObserver at threshold 0.5; `render` and `viewport` are -applied by a document scan on enable and after each navigation. +so a pointer passing over a link does not fetch it. `viewport` uses an +IntersectionObserver at threshold 0.5 and waits a ~250ms dwell, cancelled +the instant the link scrolls back out, so a fast scroll through a long +list spends no requests (the same gate Astro / Next / Nuxt / Remix / +TanStack / Turbo apply). On touch, `touchstart` additionally warms the +tapped link itself (a single request for a link about to be navigated). +`render` and `viewport` are applied by a document scan on enable and after +each navigation. Only internal links qualify, using the same eligibility as a click: cross-origin, `download`, `target` other than `_self`, non-HTML @@ -602,7 +617,9 @@ Opt out with `data-prefetch="none"`, `data-no-prefetch`, or `rel="external"`. Speculation is bounded by a concurrency cap (excess requests queue and drain as slots free, rather than being dropped), in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under -`Save-Data` or `prefers-reduced-data`. A mutating form submission and +`Save-Data`, `prefers-reduced-data`, or a 2g `effectiveType` connection. +The guiding rule: snappy, but never at the cost of bloating the client +network tab; when the two conflict, the gate under-fetches. A mutating form submission and `revalidate(url)` both evict the prefetch cache alongside the snapshot cache, so a fragment prefetched before a mutation is never served stale. diff --git a/docs/app/docs/client-router/page.ts b/docs/app/docs/client-router/page.ts index 642be1a36..fd6d670bf 100644 --- a/docs/app/docs/client-router/page.ts +++ b/docs/app/docs/client-router/page.ts @@ -188,13 +188,16 @@ revalidate();

Mutating form submissions (POST / PUT / PATCH / DELETE) clear the cache automatically on success. You only need revalidate() when the mutation happens via JS / RPC and didn't go through a form.

Link prefetch (on by default)

-

Same-origin in-app links are prefetched speculatively, so a click resolves from a warm cache with no round-trip. The default strategy is intent: a brief hover, focus, or touch (after a short dwell) fetches the page with the same headers a real navigation sends, and the click then consumes that fragment. No attribute is needed; it is on for every internal <a href>, the way Next, Nuxt, and SvelteKit ship auto-prefetch.

-

Choose a different strategy per link with data-prefetch (a valid-HTML data-* attribute, since webjs has no Link component). Next-style aliases are accepted:

-
<a href="/dashboard">intent: hover / focus / touch (default)</a>
+    

Same-origin in-app links are prefetched speculatively, so a click resolves from a warm cache with no round-trip. No attribute is needed; it is on for every internal <a href>, the way Next, Nuxt, and SvelteKit ship auto-prefetch, and the prefetch sends the same headers a real navigation does so the click consumes the fragment.

+

The default strategy is device-adaptive, because one strategy cannot serve both input modalities. On a hover-capable pointer (mouse / trackpad) the default is intent (warm on hover or focus, a real head-start before the click). On touch the default is viewport (warm as links settle on-screen), because touch has no hover and touchstart fires at tap time, too late to help. The modality is detected with matchMedia('(hover: hover) and (pointer: fine)'), not a user-agent sniff, and a per-link data-prefetch always overrides it.

+

Choose a strategy per link with data-prefetch (a valid-HTML data-* attribute, since webjs has no Link component). Next-style aliases are accepted:

+
<a href="/dashboard">adaptive: intent on pointer, viewport on touch (default)</a>
+<a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
 <a href="/dashboard" data-prefetch="render">eager, on insert (alias: true)</a>
 <a href="/dashboard" data-prefetch="viewport">on scroll into view (alias: auto)</a>
 <a href="/dashboard" data-prefetch="none">never (alias: false)</a>
-

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 or prefers-reduced-data. A mutating form submission and revalidate() evict the prefetch cache too, so a fragment prefetched before a mutation is never served stale.

+

The viewport strategy waits a ~250ms dwell before warming and cancels the instant a link scrolls back out, so a fast scroll through a long list spends no requests (the same over-fetch gate Astro, Next, Nuxt, Remix, TanStack, and Turbo apply). On touch, touchstart additionally warms the tapped link itself. The guiding rule is snappy without bloating the network tab: when the two conflict, the gate under-fetches.

+

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.

Per-segment loading skeletons

From 4a85d8c77df39c45915403fecf847d4edc244ecd Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 15:08:43 +0530 Subject: [PATCH 4/7] test: e2e network probe for viewport prefetch on scroll-into-view Prove the viewport path end-to-end against the real server wire (the browser test stubs fetch): a data-prefetch=viewport link below the fold issues no prefetch on load, and warms exactly once after it scrolls in and dwells. --- test/e2e/e2e.test.mjs | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index e11d84780..fee16303a 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -2294,6 +2294,54 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 } }); + test('prefetch: a data-prefetch="viewport" link warms only after it scrolls into view, not on load (#530)', async () => { + await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await sleep(2000); + + // A unique query gives a distinct prefetch-cache key, so a prior test that + // warmed /about cannot mask this one. /about is a real page (200 text/html). + const vpHref = '/about?probe=e2e-viewport'; + await page.evaluate((href) => { + const main = document.querySelector('main') || document.body; + // Push the link far below the fold so it starts OUTSIDE the viewport. + const spacer = document.createElement('div'); + spacer.id = 'e2e-vp-spacer'; + spacer.style.height = '4000px'; + const a = document.createElement('a'); + a.href = href; + a.id = 'e2e-vp-link'; + a.setAttribute('data-prefetch', 'viewport'); + a.textContent = 'viewport link'; + main.append(spacer, a); + // A re-scan (what a soft nav fires) makes the router observe the new link. + document.dispatchEvent(new Event('webjs:navigate')); + window.scrollTo(0, 0); + }, vpHref); + + let warmed = 0; + const origin = new URL(baseUrl).origin; + const onRequest = (req) => { + if (!req.headers()['x-webjs-prefetch']) return; + let u; try { u = new URL(req.url()); } catch { return; } + if (u.origin === origin && u.pathname === '/about' && u.search.includes('probe=e2e-viewport')) warmed++; + }; + page.on('request', onRequest); + try { + // Off-screen: the viewport link must NOT prefetch on load (not eager). + await sleep(700); + assert.equal(warmed, 0, 'a viewport link below the fold must not prefetch before it is seen'); + + // Scroll it into view and let it settle past the ~250ms dwell. + await page.evaluate(() => document.getElementById('e2e-vp-link')?.scrollIntoView()); + await waitFor(() => warmed >= 1, 4000, + () => `a viewport link scrolled into view should warm once it dwells (got ${warmed})`); + await sleep(300); + assert.equal(warmed, 1, 'a settled viewport link warms exactly once'); + } finally { + page.off('request', onRequest); + } + }); + test('chat: sending a message keeps you on the page and the message survives (#150)', async () => { // The chat form calls e.preventDefault() and sends over WebSocket, so the // client router must NOT intercept it (its submit listener is bubble, so the From 172bd98ddf77dfe976144adb0d0d7cc9ee95d190 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 19:06:35 +0530 Subject: [PATCH 5/7] fix: cancel pending viewport prefetch timers on re-scan + teardown Self-review found two over-fetch holes in the dwell-timer bookkeeping. The webjs:navigate re-scan disconnected the observer but left pending dwell timers armed, so a timer for an anchor the soft-nav swap removed fired a prefetch for a stale URL (no exit callback ever comes for a gone node). Clear the timers on the re-scan too. Also guard prefetch() with the enabled flag so any leftover hover/queue/dwell timer that fires after disableClientRouter cannot issue a fetch. --- packages/core/src/router-client.js | 9 +++++++++ .../routing/browser/prefetch-viewport.test.js | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index acef853a2..98dac5a36 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -1189,6 +1189,9 @@ function prefetchMode(anchor) { * @param {string} href */ function prefetch(href) { + // Never speculate once the router is torn down: a leftover hover / queue / + // dwell timer that fires after disableClientRouter must not issue a fetch. + if (!enabled) return; if (typeof fetch !== 'function') return; if (prefetchSaysSaveData()) return; const key = cacheKey(href); @@ -1410,7 +1413,13 @@ function refreshPrefetchObservers() { } }, { threshold: 0.5 }); } else { + // Re-scan: drop the old observation set AND cancel any pending dwell + // timers, so a timer armed for an anchor the soft-nav swap removed cannot + // fire a prefetch for a stale URL (its exit callback never comes once it + // is gone). Anchors still on-screen re-arm when observe() below redelivers + // their current intersection state. prefetchViewObserver.disconnect(); + clearPrefetchViewTimers(); } } for (const anchor of document.querySelectorAll('a[href]')) { diff --git a/packages/core/test/routing/browser/prefetch-viewport.test.js b/packages/core/test/routing/browser/prefetch-viewport.test.js index 6cfa8e32c..ec4b6f490 100644 --- a/packages/core/test/routing/browser/prefetch-viewport.test.js +++ b/packages/core/test/routing/browser/prefetch-viewport.test.js @@ -119,6 +119,26 @@ suite('Client router: viewport prefetch over-fetch gate (#530)', () => { } finally { teardown(); } }); + test('a soft-nav re-scan cancels a pending dwell timer for a removed link', async () => { + setup(); + try { + const a = addViewportAnchor('/rescan-target'); + await settle(); + const obs = viewObserver(); + + obs.emit(a, true); // arm the dwell timer + await sleep(40); // still within the 250ms dwell + // The soft-nav swap removes the link, then the router re-scans. The + // removed anchor will never get an exit callback, so the re-scan itself + // must cancel its pending timer (otherwise it warms a stale URL). + a.remove(); + document.dispatchEvent(new Event('webjs:navigate')); + + await sleep(PAST_DWELL); + assert.equal(calls.length, 0, 'a pending timer for a removed link is cancelled on re-scan, not fired'); + } finally { teardown(); } + }); + test('re-entering after a cancel can still warm (the anchor is not poisoned)', async () => { setup(); try { From bbd61298b7b9f5a3c2e8382cc58c4225dd61cfcf Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 19:55:09 +0530 Subject: [PATCH 6/7] test: keep e2e prefetch/streaming deterministic under the adaptive default Headless Puppeteer in CI reports hover:none, so the adaptive default resolves to viewport there. That broke two classes of existing e2e: the hover-prefetch tests (a bare link no longer warms on hover) and the progressive-streaming tests (the trigger link got viewport-prefetched and buffered, so the soft-nav consumed a resolved page and the live fallback never showed). Pin the hover tests to data-prefetch=intent, opt the streaming triggers out with data-no-prefetch, and add a touch-emulated test that a bare link defaults to viewport prefetch (#530). --- test/e2e/e2e.test.mjs | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index fee16303a..a4eb78a65 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -336,6 +336,15 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 // (past 400ms) and advance the URL only once the content was already // resolved, so the fallback would never be observed live. await page.goto(baseUrl + '/about', { waitUntil: 'domcontentloaded', timeout: 10000 }); + // Keep this nav LIVE: the default prefetch is device-adaptive, so on a + // no-hover runner the home link would be viewport-prefetched and buffered + // (a prefetch reads the full body, resolving the boundary), and the click + // would then consume that buffer and never show the live fallback. Opting + // the trigger out of prefetch keeps the test about streaming, not prefetch. + await page.evaluate(() => { + const home = [...document.querySelectorAll('a')].find((a) => a.getAttribute('href') === '/'); + if (home) home.setAttribute('data-no-prefetch', ''); + }); await sleep(1500); // Click the layout's home link (href="/") to soft-navigate to the homepage. @@ -611,6 +620,14 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 test('progressive soft-nav to a streamed page keeps the fallback live at URL advance (#471/#473)', async () => { await page.goto(baseUrl + '/static-info', { waitUntil: 'domcontentloaded', timeout: 10000 }); + // Keep the nav to the streamed page LIVE (not prefetch-buffered): the + // adaptive default would viewport-prefetch the Stream link on a no-hover + // runner, buffering the resolved page and defeating the streaming assertion. + await page.evaluate(() => { + for (const a of document.querySelectorAll('nav a')) { + if (a.textContent.trim() === 'Stream') { a.setAttribute('data-no-prefetch', ''); break; } + } + }); await sleep(1200); // Click the "Stream" nav link (soft navigation to /stream-demo). await page.evaluate(() => { @@ -2111,6 +2128,10 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 const a = document.createElement('a'); a.href = '/about'; a.id = 'e2e-prefetch-link'; + // Pin the strategy to intent: this test exercises the hover/intent path, + // and the default is now device-adaptive (viewport on a no-hover runner), + // so a bare default link would not warm on hover under headless Puppeteer. + a.setAttribute('data-prefetch', 'intent'); a.textContent = 'about (e2e)'; (document.querySelector('main') || document.body).appendChild(a); window.__e2ePrefetchSentinel = 'alive'; @@ -2250,6 +2271,9 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 const ok = document.createElement('a'); ok.href = '/about'; ok.id = 'e2e-ctrl-link'; + // Pin intent: the default is device-adaptive now, so a no-hover runner + // would resolve a bare link to viewport and the hover control would fail. + ok.setAttribute('data-prefetch', 'intent'); ok.textContent = 'control'; const ext = document.createElement('a'); ext.href = 'https://example.com/somewhere'; @@ -2342,6 +2366,54 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 } }); + test('prefetch: on a TOUCH-emulated viewport, a bare link (no data-prefetch) defaults to viewport prefetch (#530)', async () => { + // Emulate a touch device (no hover, coarse pointer): the adaptive default + // must resolve a bare link to `viewport`, so it warms on scroll-into-view + // rather than needing a hover that touch cannot produce. Scoped + reset in + // finally so the emulation does not leak into later tests. + await page.emulateMediaFeatures([{ name: 'hover', value: 'none' }, { name: 'pointer', value: 'coarse' }]); + try { + await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await sleep(2000); + + const touchHref = '/about?probe=e2e-touch-default'; + await page.evaluate((href) => { + const main = document.querySelector('main') || document.body; + const spacer = document.createElement('div'); + spacer.style.height = '4000px'; + const a = document.createElement('a'); + a.href = href; // NO data-prefetch: relies on the adaptive default + a.id = 'e2e-touch-link'; + a.textContent = 'touch default link'; + main.append(spacer, a); + document.dispatchEvent(new Event('webjs:navigate')); + window.scrollTo(0, 0); + }, touchHref); + + let warmed = 0; + const origin = new URL(baseUrl).origin; + const onRequest = (req) => { + if (!req.headers()['x-webjs-prefetch']) return; + let u; try { u = new URL(req.url()); } catch { return; } + if (u.origin === origin && u.pathname === '/about' && u.search.includes('probe=e2e-touch-default')) warmed++; + }; + page.on('request', onRequest); + try { + // Off-screen: no hover exists on touch, so nothing warms yet. + await sleep(700); + assert.equal(warmed, 0, 'a bare link off-screen on touch does not warm (no hover to trigger it)'); + // Scroll it in and let it dwell: the adaptive default warms it as viewport. + await page.evaluate(() => document.getElementById('e2e-touch-link')?.scrollIntoView()); + await waitFor(() => warmed >= 1, 4000, + () => `a bare link on touch should warm as viewport once it dwells (got ${warmed})`); + } finally { + page.off('request', onRequest); + } + } finally { + await page.emulateMediaFeatures([]); // reset so later tests run at the runner's native modality + } + }); + test('chat: sending a message keeps you on the page and the message survives (#150)', async () => { // The chat form calls e.preventDefault() and sends over WebSocket, so the // client router must NOT intercept it (its submit listener is bubble, so the From 0e604133ab845e67816e18c47e826d3c0962c9d8 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 20:21:33 +0530 Subject: [PATCH 7/7] test: control e2e prefetch modality with a matchMedia shim The previous attempt used per-test data-prefetch pins and emulateMediaFeatures, but CI Chromium does not support the hover media feature (it threw 'Unsupported media feature: hover'), and pinning each link did not address the real cause: on a no-hover runner the adaptive default makes every link viewport, so a page gets passively warmed into the prefetch cache before a hover test can observe a fresh fetch. Install a matchMedia shim via evaluateOnNewDocument that models a desktop pointer by default (restoring main's behaviour for the existing hover and streaming tests with no per-test hacks) and reads window.__webjsForceTouch so the touch test opts into no-hover. The shim is plain JS, so it works on every Chromium and controls the modality deterministically. --- test/e2e/e2e.test.mjs | 66 +++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index a4eb78a65..0f9d4edee 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -126,6 +126,28 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 args: ['--no-sandbox', '--disable-setuid-sandbox'], }); page = await browser.newPage(); + // Model a desktop pointer deterministically. Headless Chromium reports + // hover:none, which would flip the adaptive prefetch default to viewport + // and passively warm links (breaking tests that assume nothing prefetches + // without a hover). Override ONLY the hover/pointer media query (other + // queries delegate to the real matchMedia); a test opts into touch by + // setting window.__webjsForceTouch before the router scans. This uses a + // matchMedia shim rather than emulateMediaFeatures, which CI's Chromium + // does not support for the hover feature. + await page.evaluateOnNewDocument(() => { + const real = window.matchMedia.bind(window); + window.matchMedia = (q) => { + if (typeof q === 'string' && q.includes('hover')) { + const touch = !!window.__webjsForceTouch; + return { + matches: !touch, media: q, onchange: null, + addEventListener() {}, removeEventListener() {}, + addListener() {}, removeListener() {}, dispatchEvent() { return false; }, + }; + } + return real(q); + }; + }); }); after(async () => { @@ -336,15 +358,6 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 // (past 400ms) and advance the URL only once the content was already // resolved, so the fallback would never be observed live. await page.goto(baseUrl + '/about', { waitUntil: 'domcontentloaded', timeout: 10000 }); - // Keep this nav LIVE: the default prefetch is device-adaptive, so on a - // no-hover runner the home link would be viewport-prefetched and buffered - // (a prefetch reads the full body, resolving the boundary), and the click - // would then consume that buffer and never show the live fallback. Opting - // the trigger out of prefetch keeps the test about streaming, not prefetch. - await page.evaluate(() => { - const home = [...document.querySelectorAll('a')].find((a) => a.getAttribute('href') === '/'); - if (home) home.setAttribute('data-no-prefetch', ''); - }); await sleep(1500); // Click the layout's home link (href="/") to soft-navigate to the homepage. @@ -620,14 +633,6 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 test('progressive soft-nav to a streamed page keeps the fallback live at URL advance (#471/#473)', async () => { await page.goto(baseUrl + '/static-info', { waitUntil: 'domcontentloaded', timeout: 10000 }); - // Keep the nav to the streamed page LIVE (not prefetch-buffered): the - // adaptive default would viewport-prefetch the Stream link on a no-hover - // runner, buffering the resolved page and defeating the streaming assertion. - await page.evaluate(() => { - for (const a of document.querySelectorAll('nav a')) { - if (a.textContent.trim() === 'Stream') { a.setAttribute('data-no-prefetch', ''); break; } - } - }); await sleep(1200); // Click the "Stream" nav link (soft navigation to /stream-demo). await page.evaluate(() => { @@ -2128,10 +2133,6 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 const a = document.createElement('a'); a.href = '/about'; a.id = 'e2e-prefetch-link'; - // Pin the strategy to intent: this test exercises the hover/intent path, - // and the default is now device-adaptive (viewport on a no-hover runner), - // so a bare default link would not warm on hover under headless Puppeteer. - a.setAttribute('data-prefetch', 'intent'); a.textContent = 'about (e2e)'; (document.querySelector('main') || document.body).appendChild(a); window.__e2ePrefetchSentinel = 'alive'; @@ -2271,9 +2272,6 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 const ok = document.createElement('a'); ok.href = '/about'; ok.id = 'e2e-ctrl-link'; - // Pin intent: the default is device-adaptive now, so a no-hover runner - // would resolve a bare link to viewport and the hover control would fail. - ok.setAttribute('data-prefetch', 'intent'); ok.textContent = 'control'; const ext = document.createElement('a'); ext.href = 'https://example.com/somewhere'; @@ -2366,15 +2364,17 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 } }); - test('prefetch: on a TOUCH-emulated viewport, a bare link (no data-prefetch) defaults to viewport prefetch (#530)', async () => { - // Emulate a touch device (no hover, coarse pointer): the adaptive default - // must resolve a bare link to `viewport`, so it warms on scroll-into-view - // rather than needing a hover that touch cannot produce. Scoped + reset in - // finally so the emulation does not leak into later tests. - await page.emulateMediaFeatures([{ name: 'hover', value: 'none' }, { name: 'pointer', value: 'coarse' }]); + test('prefetch: on a TOUCH viewport, a bare link (no data-prefetch) defaults to viewport prefetch (#530)', async () => { + // Force the touch modality (no hover) through the matchMedia shim the before + // hook installs, so the adaptive default resolves a bare link to `viewport` + // and it warms on scroll-into-view rather than needing a hover that touch + // cannot produce. Reset the flag in finally so later tests keep the desktop + // model. CI Chromium does not support emulateMediaFeatures for hover, so the + // shim is how the modality is controlled. + await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 15000 }); try { - await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 15000 }); - await sleep(2000); + await page.evaluate(() => { window.__webjsForceTouch = true; }); + await sleep(1500); const touchHref = '/about?probe=e2e-touch-default'; await page.evaluate((href) => { @@ -2410,7 +2410,7 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 page.off('request', onRequest); } } finally { - await page.emulateMediaFeatures([]); // reset so later tests run at the runner's native modality + await page.evaluate(() => { window.__webjsForceTouch = false; }).catch(() => {}); } });