Skip to content

dogfood: links are clickable before the client router intercepts, so an early click full-loads #1118

Description

@vivek7405

Problem

There is a window at the start of every page load where links are already clickable but the client router is not yet listening. A click inside it is a plain browser navigation: a full document load, with the tab spinner and a whole-page flash, on a site where the router is supposed to soft-navigate.

This is structural, not a race that can be tuned away. enableClientRouter() runs at module scope in packages/core/src/router-client.js (bottom of the file), and the boot is emitted as <script type="module"> (packages/server/src/ssr.js:1270). Module scripts are deferred by spec, so they execute only after HTML parsing completes. Links, meanwhile, are in the SSR'd HTML and clickable from first paint. No amount of caching closes this: a deferred script cannot run during parse, by definition.

Measured on the real website, timing when the document click listener is actually installed:

Condition Router listening at
Warm cache (repeat visit) 25 to 35 ms
Cold cache ~39 ms
Cold, throttled 300 kbps / 200 ms RTT 313 ms

Scope, stated honestly: at 25 to 35 ms warm this is not human-clickable in normal use, and three separate rigs driving the live site (56 clicks total, including with DevTools "Disable cache" on) failed to reproduce a single unintercepted click. So this is NOT the cause of the flash reported in #1114 (that was a poisoned prefetch entry, fixed in #1115). It is a real gap that shows up for first-time visitors on slow connections and for developers with "Disable cache" checked, where the window is network-sized and each resulting full load restarts it.

Filed so it is tracked rather than surviving as folklore, since it was investigated and set aside twice during #1114.

Design / approach

A synchronous inline script in <head> is the only thing that can close the window, because it is the only script that runs before the parser reaches the links. Shape:

  1. Inline <script> (no type="module", no defer) in the head, emitted by the same code that emits the boot.
  2. It installs a capture-phase click listener that, for a qualifying same-origin link, calls preventDefault() and records the URL in a module-visible slot (e.g. window.__webjsPendingNav).
  3. When enableClientRouter() runs it drains that slot and performs the soft navigation.
  4. A timeout escape hatch (a few hundred ms) releases a captured click as a real navigation if the router never arrives, so a broken or blocked bundle cannot strand the user on a dead link. This is the critical safety property: capturing a click and then never acting on it is worse than the bug.

Turbo takes essentially this approach. Note the eligibility test must match the router's own (eligibleAnchorHref / the onClick guards: cross-origin, download, non-_self target, data-no-router, modified clicks, non-HTML extensions, pure hash jumps), or the inline capture will swallow clicks the router would have let through.

Second, cheaper, independent improvement: the core bundle is not modulepreloaded. The served HTML preloads component modules but not webjs-core-browser.js itself, so a cold load pays a waterfall (HTML, then a component module, then discover the core import, then fetch core). Adding a preload hint for core shrinks the cold window materially without touching the interception model, and is worth doing regardless of whether the inline capture lands.

CSP consideration: WebJs supports an opt-in nonce (cspNonce()), so the inline script must carry the nonce when CSP is enabled, exactly like the other inline scripts the shell emits.

Implementation notes (for the implementing agent)

Where to edit:

  • packages/server/src/ssr.js:1270, where boot is built as <script type="module">. The inline capture goes into the head, BEFORE this. The nonce variable n in scope there is the pattern to copy for CSP.
  • packages/server/src/ssr.js, wrapHead / buildHeadInner, for placing the inline script and the core modulepreload hint.
  • packages/core/src/router-client.js: enableClientRouter() (around L301, installs the click listener at L318) is where the drain belongs; eligibleAnchorHref and onClick hold the eligibility rules the inline capture must mirror.
  • The importmap and preload emission lives in the server's importmap module; find where the component modulepreload hints are generated and add core there.

Landmines:

  • Do not capture a click you cannot honour. The timeout release is mandatory. A user on a network where the bundle never loads must still be able to navigate.
  • Duplicating the eligibility rules is a real drift hazard. Two copies of "is this link ours" will diverge. Prefer generating the inline snippet from a single source, or keep it deliberately narrower (capture only obvious same-origin same-tab plain-left-clicks) and let anything ambiguous through to the browser.
  • #1053 is an open flake about router interception escaping on Firefox in a positive-control test. Read it before touching interception; this change moves in the same area.
  • An inline script in the head runs on EVERY page, so keep it tiny (a few hundred bytes). The landing page copy advertises a ~29 KB client runtime; do not undermine that with a fat shim.
  • The scaffold's CSP guidance and global-error.{js,ts} (which ships no importmap or boot deliberately) both interact with inline scripts. Check that a global-error page is not given a capture script it cannot drain.

Invariants to respect:

  • AGENTS.md code-workflow rule 1: a client-router change needs a browser or e2e assertion, not only a unit test.
  • Progressive enhancement: with JS off, nothing changes. The inline script must be purely additive.
  • AGENTS.md invariant 11 for any prose added.

Tests:

  • Browser test asserting a click dispatched BEFORE enableClientRouter() runs is captured and then soft-navigated once the router boots.
  • Browser test for the timeout release: capture a click, never boot the router, assert the navigation proceeds.
  • A counterfactual: without the inline script, the same early click produces a document load.
  • Note the measurement trap from dogfood: client router intermittently falls back to a full page load (tab spinner + whole-document flash) #1114: headless Chromium reports hover: none, and framenavigated fires for same-document navigations too, so count document loads with an in-page DOMContentLoaded counter persisted to sessionStorage, not with Puppeteer's navigation events.

Docs surfaces: .agents/skills/webjs/references/client-router-and-streaming.md (the client-router section) and website/app/docs/client-router/page.ts if the behaviour becomes author-visible.

Acceptance criteria

  • A click on a same-origin link before the router boots results in a SOFT navigation, not a document load
  • A captured click is released as a real navigation if the router never arrives, within a bounded timeout
  • The inline capture's eligibility matches the router's, or is strictly narrower, with no click swallowed that the router would have passed through
  • The core bundle carries a modulepreload hint
  • Works with the opt-in CSP nonce
  • Nothing changes with JavaScript disabled
  • Browser tests cover the capture, the drain, and the timeout release, with a counterfactual
  • Docs updated if the behaviour is author-visible

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions