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
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 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.jshtmlResponse() 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.
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
Problem
The client router detects a deploy by comparing the
x-webjs-build/x-webjs-srcresponseheaders 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-ageon its pages (which the framework supports and documents viametadata.cacheControl), the browser can satisfy the router's fetch entirely from cache andhand 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 arevalidation on every router fetch and so always produced live headers. Found while raising
webjs.dev's pages to
max-age=60in #1127 / #1130, where it is now a live (accepted,documented) tradeoff.
The two fetch sites, both with no
cacheoption: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 itpartly defeats the point of an app opting into a browser
max-agefor soft navigation. TheETag path keeps that cheap, provided the page renders deterministically (see dogfood: webjs.dev pages are never cached at any layer #1127).
PREFETCH, since the prefetch is the early-warning path the eviction logic was built around
(see the
#899comment in the prefetch handler).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-ageon 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 thedeploy check immediately after it that reads
x-webjs-build/x-webjs-srcand comparesthem against the
script[type="importmap"]element'sdata-webjs-build/data-webjs-srcattributes.
const init = { method, headers, credentials: 'same-origin' }block).packages/server/src/ssr.jshtmlResponse()is wherex-webjs-build/x-webjs-srcarestamped, useful context for why they are per-response rather than per-build-artifact.
Landmines
is the warmup "version unknown" state and must never read as a deploy. Preserve that.
applySwaphard-reloads on a build change and soft-applies on a src-only change. A fix thatmakes 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.
metadata.cacheControlis a supported, documented feature (website/app/docs/cache), anddogfood: webjs.dev pages are never cached at any layer #1127 exists precisely because the previous default made pages uncacheable.
cache: 'no-cache'changes what the network tab shows for every soft navigation, soany e2e that counts requests or asserts on cache state may need updating.
Invariants to respect
keep that property and keep dispatching
webjs:navigation-fallbackon degradation.usable validator, so pair this with the determinism requirement from dogfood: webjs.dev pages are never cached at any layer #1127.
Tests + docs surfaces
max-age, changes the server's build id, and asserts the router still evicts its caches.test/e2e/*.test.mjsalready exercises navigation and network probes.references/client-router-and-streaming.mddocuments the prefetch and deploy-detectionbehaviour and should state the interaction with a browser-cached page.
website/app/docs/cache/page.tsandwebsite/app/docs/client-router/page.tsfor theuser-facing side of the same tradeoff.
Acceptance criteria
max-agestill gets its deploy detected by the routeris fresh in the HTTP cache
metadata.cacheControl