Skip to content

fix: a browser-cached page hides a deploy from the client router #1131

Description

@vivek7405

Problem

The client router detects a deploy by comparing the x-webjs-build / x-webjs-src response
headers against the ids the current page booted with. Those headers are read off a fetch()
that uses the browser's DEFAULT HTTP cache mode, so as soon as an app sets a browser-level
max-age on its pages (which the framework supports and documents via
metadata.cacheControl), the browser can satisfy the router's fetch entirely from cache and
hand it the STORED, pre-deploy headers.

The check then compares two equally stale ids, concludes no deploy happened, and skips the
snapshot / prefetch eviction. The result is a soft swap of pre-deploy markup into a
post-deploy page, for as long as the page stays fresh in the HTTP cache.

This is invisible today only because every in-repo app used max-age=0, which forced a
revalidation on every router fetch and so always produced live headers. Found while raising
webjs.dev's pages to max-age=60 in #1127 / #1130, where it is now a live (accepted,
documented) tradeoff.

The two fetch sites, both with no cache option:

// packages/core/src/router-client.js, prefetch
fetch(href, { method: 'GET', headers, credentials: 'same-origin' })

// packages/core/src/router-client.js, navigation
const init = { method, headers, credentials: 'same-origin' };
const resp = await fetch(href, init);

Design / approach

The router's staleness detector should not be readable from the same cache whose staleness it
exists to detect. The straightforward fix is to send these fetches with cache: 'no-cache',
which forces a revalidation while still allowing a 304 (so the cost is a conditional request,
not a full re-download, and only when the page carries a validator).

Worth deciding deliberately rather than by default:

  • cache: 'no-cache' on BOTH the prefetch and navigation fetches is the safe baseline, but it
    partly defeats the point of an app opting into a browser max-age for soft navigation. The
    ETag path keeps that cheap, provided the page renders deterministically (see dogfood: webjs.dev pages are never cached at any layer #1127).
  • A narrower option is to leave the navigation fetch alone and only force revalidation on the
    PREFETCH, since the prefetch is the early-warning path the eviction logic was built around
    (see the #899 comment in the prefetch handler).
  • A third option is to stop trusting cache-able headers for this signal at all and carry the
    build id somewhere a cached response cannot stale, though there may not be a clean channel
    for that without a separate request.

Whichever is picked, the invariant to state explicitly is: an app setting a browser max-age
on its HTML must not be able to silently disable post-deploy detection.

Implementation notes (for the implementing agent)

Where to edit

  • packages/core/src/router-client.js, the prefetch fetch (search for 'x-webjs-prefetch',
    the fetch(href, { method: 'GET', headers, credentials: 'same-origin' }) call) and the
    deploy check immediately after it that reads x-webjs-build / x-webjs-src and compares
    them against the script[type="importmap"] element's data-webjs-build / data-webjs-src
    attributes.
  • The navigation fetch in the same file (the const init = { method, headers, credentials: 'same-origin' } block).
  • packages/server/src/ssr.js htmlResponse() is where x-webjs-build / x-webjs-src are
    stamped, useful context for why they are per-response rather than per-build-artifact.

Landmines

  • The deploy check deliberately requires BOTH ids of a pair to be present, because an empty id
    is the warmup "version unknown" state and must never read as a deploy. Preserve that.
  • applySwap hard-reloads on a build change and soft-applies on a src-only change. A fix that
    makes deploys detectable more often will make that hard-reload path fire more often too, so
    verify it does not turn a routine deploy into a reload storm for users mid-session.
  • Do not "fix" this by disabling the browser cache for HTML generally. Apps opting into
    metadata.cacheControl is a supported, documented feature (website/app/docs/cache), and
    dogfood: webjs.dev pages are never cached at any layer #1127 exists precisely because the previous default made pages uncacheable.
  • Adding cache: 'no-cache' changes what the network tab shows for every soft navigation, so
    any e2e that counts requests or asserts on cache state may need updating.

Invariants to respect

  • The client router degrades to a full page load rather than guessing; any change here must
    keep that property and keep dispatching webjs:navigation-fallback on degradation.
  • Prefetch must not bloat the network tab. A revalidation is cheap only when the page has a
    usable validator, so pair this with the determinism requirement from dogfood: webjs.dev pages are never cached at any layer #1127.

Tests + docs surfaces

  • Browser / e2e is where this actually lives: a test that boots a page with a browser
    max-age, changes the server's build id, and asserts the router still evicts its caches.
    test/e2e/*.test.mjs already exercises navigation and network probes.
  • references/client-router-and-streaming.md documents the prefetch and deploy-detection
    behaviour and should state the interaction with a browser-cached page.
  • website/app/docs/cache/page.ts and website/app/docs/client-router/page.ts for the
    user-facing side of the same tradeoff.

Acceptance criteria

  • A page served with a browser max-age still gets its deploy detected by the router
  • The prefetch-time eviction fires when the server's build id changes, even while the page
    is fresh in the HTTP cache
  • The warmup "version unknown" empty-id case is still not treated as a deploy
  • A counterfactual proves the new test fails when the cache mode change is reverted
  • The client-router reference documents the interaction with metadata.cacheControl

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