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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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