Skip to content

dogfood: client router intermittently falls back to a full page load (tab spinner + whole-document flash) #1114

Description

@vivek7405

Problem

Clicking links on webjs.dev intermittently shows a loading spinner in the browser tab and flashes the ENTIRE document, navbar included. Reproduction (live site, real browser, roughly every other lap):

  1. Load https://webjs.dev
  2. Go to /docs
  3. Click the top-left logo to return to the landing page
  4. Go to /docs again
  5. Sometimes the whole document flashes with a tab spinner, sometimes it does not

The tab spinner and the flash are ONE event: a main-frame document load. With the client router enabled, clicking a same-origin link should never do that.

Root cause (found, captured, fixed on PR #1115)

The client router consumes a POISONED PREFETCH ENTRY, finds the fragment shares no boundary with the live DOM, and correctly degrades to location.href (the #1015 integrity fallback). Captured cause string, via the new webjs:navigation-fallback event, on every failure: no-shared-boundary.

How the poison is planted, from the captured request log:

/docs/getting-started   have=/:/                     prefetch=1   <- hover on home: correct
/docs/getting-started   have=/docs/...,/docs,/:/     prefetch=1   <- fired FROM the docs page itself
NAV-REQUESTED: scriptInitiated -> /docs/getting-started            <- the reload
  1. Hover the /docs link on the landing page. The intent prefetcher starts its dwell timer.
  2. Click. The soft nav lands; the DOM is now the docs page.
  3. The hover's timer fires ANYWAY and prefetches /docs/getting-started, computing X-Webjs-Have from the NOW-SWAPPED DOM. The server correctly answers "you already have everything" with a near-empty fragment. (dogfood: the client router prefetches the page you are already on #1106 documented this same wasted request from the duplicate-fetch angle.)
  4. The fragment is cached under the URL, TTL 30 s. Hovering again does NOT refresh it: the fresh-entry check skips the refetch.
  5. Return to the landing page, click /docs again. The click consumes the poisoned entry, applySwap finds no shared boundary, and the router does the only safe thing: a full page load. Spinner, flash.
  6. The reload wipes the in-page cache, so the next lap is clean, and the lap after re-poisons: the repro alternates, which is the "sometimes".

Two composing framework defects:

  • prefetch() could prefetch the page the user is already on (dogfood: the client router prefetches the page you are already on #1106).
  • prefetchTake() ignored the X-Webjs-Have context an entry was fetched against. The server marks the reduced response Vary: X-Webjs-Have; the prefetch cache is a client-side cache of that response and violated its own vary dimension.

Reproduced 6-of-24 clicks before the fix, 0-of-24 after, same randomized hover dwells, via a fine-pointer + hover-dwell puppeteer rig (headless defaults report hover: none, which is why earlier rigs missed it: intent prefetch never ran).

A third defect made all of this undiagnosable in production and is fixed on the same PR: the router's full-page-load degradations were logged by a DEV-ONLY console warning (devWarnFallback), and the deploy-mismatch reload had no diagnostic at all. reportFallback now dispatches webjs:navigation-fallback on document in every environment with { cause, href, willReload }.

Ruled out along the way (measured, do not re-investigate)

  • CSSOM churn from a swapped page/layout <style> (dogfood: a page/layout <style> churns the CSSOM on every navigation, flashing preserved layouts #1109's attribution): about 4.6 ms of extra style recalc per navigation. Real waste, cannot flash a document.
  • Cloudflare serving a reduced fragment to a full-document request (Vary: X-Webjs-Have unhonoured): every response on the repro path is cf-cache-status: DYNAMIC, nothing cached, cannot fire. Remains a latent hazard if HTML caching is ever enabled.
  • The startup interception window (module boot is deferred, links clickable before the router listens): real, but 25 to 35 ms warm-cache, not human-clickable. Network-sized only with DevTools "Disable cache" or a genuine first visit; worth closing separately someday, not this bug.
  • Deploy-detection reload (dogfood: SSR-only deploy not reflected on client nav (build id misses it) #899): webjs:importmap-reload flag was never set on any captured failure.
  • Cloudflare Speed Brain (/cdn-cgi/speculation is 200 on webjs.dev): explains the second hover fetch a user sees in the Network tab; a speculation prefetch cannot hijack an intercepted click.

Acceptance criteria

  • prefetch() never fetches the current page (also closes dogfood: the client router prefetches the page you are already on #1106)
  • prefetchTake() rejects an entry whose stored have differs from the live one
  • Every router full-page-load degradation dispatches webjs:navigation-fallback with its cause, in production
  • A browser-level regression test drives hover, swap, then click and asserts the nav stays soft; counterfactual red without the fix
  • Unit tests cover both guards
  • The reported cycle, repeated in a real browser with hover, produces no document load
  • Docs updated: the prefetch section and the new event alongside webjs:navigation-error

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions