You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Inline <script> (no type="module", no defer) in the head, emitted by the same code that emits the boot.
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).
When enableClientRouter() runs it drains that slot and performs the soft navigation.
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 notmodulepreloaded. 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.
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
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 inpackages/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:
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:<script>(notype="module", nodefer) in the head, emitted by the same code that emits the boot.clicklistener that, for a qualifying same-origin link, callspreventDefault()and records the URL in a module-visible slot (e.g.window.__webjsPendingNav).enableClientRouter()runs it drains that slot and performs the soft navigation.Turbo takes essentially this approach. Note the eligibility test must match the router's own (
eligibleAnchorHref/ theonClickguards: cross-origin,download, non-_selftarget,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 notwebjs-core-browser.jsitself, 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, wherebootis built as<script type="module">. The inline capture goes into the head, BEFORE this. The nonce variablenin scope there is the pattern to copy for CSP.packages/server/src/ssr.js,wrapHead/buildHeadInner, for placing the inline script and the coremodulepreloadhint.packages/core/src/router-client.js:enableClientRouter()(around L301, installs theclicklistener at L318) is where the drain belongs;eligibleAnchorHrefandonClickhold the eligibility rules the inline capture must mirror.modulepreloadhints are generated and add core there.Landmines:
#1053is 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.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:
Tests:
enableClientRouter()runs is captured and then soft-navigated once the router boots.hover: none, andframenavigatedfires for same-document navigations too, so count document loads with an in-pageDOMContentLoadedcounter persisted tosessionStorage, not with Puppeteer's navigation events.Docs surfaces:
.agents/skills/webjs/references/client-router-and-streaming.md(the client-router section) andwebsite/app/docs/client-router/page.tsif the behaviour becomes author-visible.Acceptance criteria
modulepreloadhint