Skip to content

feat: device-adaptive link prefetch, viewport-safe on mobile - #594

Merged
vivek7405 merged 7 commits into
mainfrom
feat/mobile-viewport-prefetch
Jun 18, 2026
Merged

feat: device-adaptive link prefetch, viewport-safe on mobile#594
vivek7405 merged 7 commits into
mainfrom
feat/mobile-viewport-prefetch

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #530

The client router's default link 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.

What changed

  • Device-adaptive default. The default prefetch mode is now intent on a hover-capable fine pointer and viewport on touch, detected with matchMedia('(hover: hover) and (pointer: fine)') (not a UA sniff). A per-link data-prefetch always overrides.
  • Over-fetch gate on the viewport path. A viewport link must dwell on-screen ~250ms before it warms, and the timer is cancelled the instant it scrolls back out (or the soft-nav re-scan removes it), so a fast scroll through a long list spends no requests. This matches what Astro/Next/Nuxt/Remix/TanStack/Turbo all ship.
  • touchstart still warms the tapped link (a single request for a link about to be navigated), even when its mode is viewport.
  • Connection gate broadened to skip a 2g effectiveType, not just Save-Data / prefers-reduced-data. prefetch() is also guarded by the enabled flag so no leftover timer fetches after teardown.

Design north star (per the issue + maintainer): snappy, but never at the cost of bloating the client network tab. When snappy and extra bytes conflict, under-fetch wins.

Test plan

  • Unit: adaptive default (pointer vs touch), matchMedia-absent fallback, 2g suppression + 4g counterfactual (packages/core/test/routing/router-client.test.js). Full node suite 2615 pass.
  • Browser: viewport dwell + cancel-on-exit + re-scan-cancel + re-entry, via a stubbed IntersectionObserver (packages/core/test/routing/browser/prefetch-viewport.test.js). Full routing browser suite 61 pass. Re-scan-cancel counterfactual verified (reverting the fix reds exactly that test).
  • E2E network probe: a data-prefetch="viewport" link below the fold warms only after it scrolls in and dwells, against the real server wire; the existing hover prefetch e2e tests still pass (no desktop regression).
  • Docs: agent-docs/advanced.md, the docs-site client-router page, and the root AGENTS.md client-router summary.
  • Dogfood: blog e2e (prefetch 3/3); website / docs / ui-website boot 200 in dist mode with all modulepreload hints resolving.
  • Bun matrix: N/A (browser-only client code in router-client.js, no node:* / server / serializer / listener surface).

Self-review ran 2 rounds; round 1 found two over-fetch holes in the dwell-timer teardown (fixed in eea7afa), round 2 clean.

Update: CI-caught regression (fixed)

Headless Puppeteer reports hover: none, so the adaptive default resolved to viewport on CI and broke the existing hover-prefetch e2e tests and the progressive-streaming e2e tests (the streamed-page trigger got viewport-prefetched and buffered). Pinned the hover tests to data-prefetch="intent", opted the streaming triggers out with data-no-prefetch, and added a touch-emulated test that a bare link defaults to viewport prefetch. Verified locally by forcing emulateMediaFeatures no-hover. See the context comment for the full writeup, including the noted out-of-scope follow-up (not prefetch-buffering a streaming response).

@vivek7405 vivek7405 self-assigned this Jun 18, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why adaptive default, dwell-gate, and where the coverage lives

A few decisions worth recording while they are fresh.

Why the default is adaptive rather than just flipping to viewport. intent is genuinely the right default on a pointer device (a mouse gives a real head-start before the click, and hovering one link is cheap). It only fails on touch, where there is no hover and touchstart fires at tap time. So the default keys on the input modality via matchMedia('(hover: hover) and (pointer: fine)'), not a UA sniff, and a per-link data-prefetch always overrides. A hybrid touch laptop with a mouse reports a fine hover pointer (primary input), so it keeps intent, which is correct there.

Why the viewport path needed a dwell + cancel, not just the existing observer. The old viewport mode fired immediately on intersection with no cancel. On a long, fast-scrolled mobile list that warms every link the scroll passes, which is exactly the network-tab bloat we do not want. I read how Astro, Next, Nuxt, Remix, TanStack, and Turbo all handle this: every one of them either delays the start (dwell) or cancels on exit, most do both. We now do both (250ms dwell, cancelled on scroll-out). The guiding rule is snappy without bloating the network tab, and when those conflict the gate under-fetches.

Why touchstart still warms the tapped link even in viewport mode. On touch the default is now viewport, but the link the user actually taps is about to be navigated anyway, so warming it on touchstart is a single free request, not speculative over-fetch. It stays.

Where the coverage lives, and a regression note. The adaptive resolution is unit-tested with a stubbed matchMedia (pointer vs touch vs absent). The dwell + cancel gate is a browser test driving a stubbed IntersectionObserver, so it is deterministic rather than scroll-timing-dependent. The e2e adds a real-wire probe (a data-prefetch="viewport" link below the fold warms only after it scrolls in and dwells). One thing I checked explicitly: headless Chromium reports a hover-capable pointer, so the adaptive default resolves to intent there, and the existing hover-based prefetch e2e tests still pass unchanged (no regression). The touch default is covered by the unit + browser layers instead.

Bun matrix is N/A. This is browser-only client code in router-client.js (no node:*, no server/serializer/listener path), so there is no runtime-sensitive surface for the Bun cross-runtime matrix to exercise.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went over the dwell/cancel bookkeeping hard, since over-fetch is the whole point of this change. Two things on the teardown paths, one real.

  1. The webjs:navigate re-scan disconnects the observer but never cancels pending dwell timers (refreshPrefetchObservers, router-client.js:1413). If an anchor had a pending timer and the soft-nav swap removes it from the DOM, the re-observe() of the fresh set never delivers a callback for the removed node, so the cancel branch never runs and the orphaned timer fires a prefetch for a stale URL. Bounded (swallowed + cache-deduped) but it still breaks the cancel-on-exit guarantee across a soft nav, which is exactly what we promised.

  2. prefetch() (router-client.js:1191) has no enabled guard. Today disableClientRouter cancels the view timers before returning so it is not reachable through that path, but the queue drain and a leftover hover timer are the same shape, and finding 1 widens it. A one-line guard closes all of them.

Everything else holds: the /2g$/ regex has no false positives, the matchMedia-absent fallback keeps the historical intent default, the touchstart branch returns early for none/render so it does not over-fetch, and the unit/browser tests are counterfactual-sound.

Comment thread packages/core/src/router-client.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, zoomed in on the round-1 fix and cross-file consistency. The re-scan now clears the dwell timers and re-observes, so on-screen anchors re-arm (a real IntersectionObserver redelivers the initial intersection on observe) while a swapped-out anchor's timer is dropped. The enabled guard in prefetch() doesn't touch any normal warm path (every caller already runs only while enabled, and the unit suite auto-enables on import). Test exports line up, the browser tests don't leak state across cases (distinct URLs, teardown disables + nulls the observer), and the new comments are clean on the punctuation rules. Nothing left to fix.

@vivek7405
vivek7405 marked this pull request as ready for review June 18, 2026 13:40
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Context: a real interaction CI caught (adaptive default vs headless runners + streamed pages)

The first CI run went red on E2E and it was a genuine find, not flake, so worth writing down.

Headless Puppeteer reports matchMedia('(hover: hover) and (pointer: fine)') as false (no hover), so on CI the adaptive default resolves a bare link to viewport, not intent. My local Chromium (new headless) reports a hover pointer, which is why it passed locally and masked this. Two existing e2e classes broke:

  1. The hover-prefetch tests injected a bare <a> and expected hover to warm it. With the default now viewport on a no-hover runner, hover no longer warms a bare link. Fixed by pinning those links to data-prefetch="intent" (they test the intent path, so they should say so).

  2. The progressive-streaming tests (feat: <webjs-suspense> streaming SSR boundary + per-component streaming (follow-up to #469) #471/feat: progressive soft-nav streaming in the client router (follow-up to #469) #473) click a nav link to a streamed page. On a no-hover runner that link is now viewport-default, so it got prefetched during the pre-click dwell, and a prefetch reads the full resp.text() which resolves every boundary. The soft-nav then consumed that buffered, fully-resolved page and the live Suspense fallback never showed. Fixed by opting the streaming trigger links out with data-no-prefetch, so the nav stays live.

I verified both by forcing emulateMediaFeatures([{hover:none},{pointer:coarse}]) locally (reproducing the CI condition): all four previously-failing tests pass with the fixes. Added a touch-emulated test that a bare link defaults to viewport prefetch end-to-end, with the emulation scoped + reset so it does not leak.

Worth flagging for later (out of scope here): viewport-prefetching a deliberately-streamed (slow) page buffers the whole thing speculatively, which both defeats its streaming on the eventual click and spends server work on a slow boundary the user may never reach. For request-stable pages that is a fine tradeoff; for a genuinely slow streamed page it leans against the no-over-fetch rule. A future option is to not prefetch-buffer a streaming response (detect the stream and skip caching it). Not doing it in this PR.

t added 7 commits June 18, 2026 20:36
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.
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.
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.
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.
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.
…fault

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).
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.
@vivek7405
vivek7405 force-pushed the feat/mobile-viewport-prefetch branch from 037d988 to 0e60413 Compare June 18, 2026 15:06
@vivek7405
vivek7405 merged commit c6eae7b into main Jun 18, 2026
17 of 18 checks passed
@vivek7405
vivek7405 deleted the feat/mobile-viewport-prefetch branch June 18, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the default link prefetch effective on mobile (viewport, not just touchstart)

1 participant