Problem
After a production deploy that changes only SSR output (no importmap change), an already-open session keeps serving pre-deploy HTML on client-router navigation, so the user must MANUALLY refresh every page to see the change. Concretely: #898 added syntax highlighting to blog code blocks AT SSR. On webjs.dev, opening a blog post after the deploy shows no highlighting until a manual refresh, and navigating to the next post shows no highlighting again, and so on for every post.
The client router already HAS deploy detection: applySwap (@webjsdev/core/src/router-client.js ~L2237-2268) compares the response's X-Webjs-Build header (incomingBuild) against the current page's data-webjs-build, and hard-reloads (location.assign) on a mismatch (the Turbo tracked_element_mismatch pattern). The gap is the BUILD ID itself: it is derived ONLY from the importmap hash (importMapHash() in @webjsdev/server/src/importmap.js L191, set at L68/137/161 as digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })))), surfaced as publishedBuildId(). An SSR-only change (syntax highlighting, a template tweak, a copy edit, a new server-rendered feature) adds no importmap entry, so the importmap hash is byte-identical across the deploy, the build id does NOT change, incomingBuild === currentBuild, no mismatch is detected, and no hard reload fires. Worse, prefetch is on by default (device-adaptive), so pages the user hovered or scrolled near BEFORE the deploy are cached in the prefetch cache as pre-deploy HTML and served verbatim on the next nav.
So the deploy is invisible to the client whenever it did not touch the importmap, which is the common case for content and SSR changes.
Design / approach
Two parts:
-
Make the published build id reflect the actual deploy, not just the importmap. Fold a per-DEPLOY fingerprint into publishedBuildId() so ANY deploy bumps it. Sources, in precedence order: an explicit WEBJS_BUILD_ID env var (deployer-set), else a detected platform deploy id (Railway's RAILWAY_GIT_COMMIT_SHA / RAILWAY_DEPLOYMENT_ID, Vercel's VERCEL_GIT_COMMIT_SHA, a generic GIT_COMMIT / SOURCE_COMMIT), else (last resort) the process boot id / start time so a restart-per-deploy platform still bumps it. Keep it STABLE within a single running process (compute once at boot), so it is not per-request (which would infinite-reload). When no deploy id is available, fall back to today's importmap-hash behavior so nothing regresses. The build id becomes hash(importmapHash + deployFingerprint) (or just the deploy fingerprint when present), keeping it a short opaque string.
-
On a detected build change, clear the WHOLE client cache, not just the current nav. Today applySwap hard-reloads the current navigation on mismatch, but the snapshot cache (snapshotCache, router-client.js ~L752) and prefetch cache (prefetchCache) may still hold pre-deploy HTML for OTHER urls. A hard reload of the current page resets JS state so those in-memory caches are cleared incidentally, but the risk is a stale-build prefetch served BEFORE the mismatch-triggering nav. So: when incomingBuild differs from the boot build (including on a prefetch response), evict every snapshot + prefetch entry (reuse revalidate() with no arg, router-client.js L314, which does snapshotCache.clear(); prefetchCache.clear()), and drop any prefetched fragment whose stored build differs from the current page build before serving it (prefetch entries already store build, router-client.js ~L1287/L1635). That guarantees no pre-deploy HTML is ever applied via a soft nav.
Prior art: Turbo's tracked_element_mismatch plus its build-tracking. WebJs already mirrors the mismatch-reload; this issue extends the SIGNAL (the id) to cover SSR-only deploys and hardens the cache eviction.
Implementation notes (for the implementing agent)
- Where to edit:
packages/server/src/importmap.js: the three _importMapHash = digestHex(...) sites (L68/137/161) and publishedBuildId() / importMapHash() (L190-192). Add a deployFingerprint() helper (read WEBJS_BUILD_ID / platform envs / boot fallback, computed once) and fold it into the published id. Confirm EVERY id consumer stays consistent: build-info.js L45 (/__webjs/version), html-cache.js L141 (the Redis HTML-cache key, which SHOULD include the deploy id so a deploy busts the server HTML cache too), the data-webjs-build attr emit (importmap.js ~L166) and the X-Webjs-Build response header.
packages/core/src/render-client.js / router-client.js: the data-webjs-build read in applySwap (~L2263), the prefetch build capture (~L1287) and use (~L1635), and revalidate() (L309). Add the "build changed -> clear all caches + drop stale-build prefetches" logic. Keep the existing "empty/absent id on either side never reloads" guard (the warmup-window protection called out at router-client.js ~L1991) so a cold or runtime-first-boot server does not wipe a half-filled form.
- Landmines / gotchas:
- The build id must be STABLE per process (compute once). A per-request or per-nonce value infinite-reloads (the exact trap the CSP-nonce stripping in
trackedReloadSignature at router-client.js already guards against).
- Do NOT reintroduce the warmup-window bug: an absent id on either side means "unknown", never a reload (router-client.js docstring ~L1991). A missing deploy fingerprint must fall back cleanly, not emit an empty id that flaps.
html-cache.js keys cached HTML by publishedBuildId() || 'nobuild' (L141); folding the deploy id in means a deploy correctly invalidates the server HTML cache too, which is desirable, but verify it does not thrash a multi-instance deploy where instances briefly disagree on the id (they converge; a brief cache miss is fine).
asset-hash.js L10 explicitly notes the importmap build id does NOT change on an app-file edit; that comment (and any behavior relying on it) may need updating.
- Bun parity:
publishedBuildId plus the header/attr emit ride the SSR + listener path; add a test/bun/*.mjs assertion that a changed WEBJS_BUILD_ID changes the emitted id on both runtimes.
- Invariants to respect: no-build (the id is computed at runtime, no bundler step).
packages/ stays plain JS + JSDoc. Progressive enhancement: the hard reload is a client-only enhancement; a JS-off user always gets fresh SSR HTML anyway.
- Tests + docs: unit (
packages/server/test/importmap/*, packages/server/test/*build*) for the id derivation + precedence; browser/e2e (test/e2e/*) for the headline (a simulated build-id bump on the server triggers a client hard reload on the next nav and evicts stale prefetches); Bun parity script. Docs: agent-docs/advanced.md (client router deploy detection), agent-docs/configuration.md plus agent-docs/built-ins.md (the new WEBJS_BUILD_ID env plus platform auto-detect), the deployment docs-site page.
Acceptance criteria
Problem
After a production deploy that changes only SSR output (no importmap change), an already-open session keeps serving pre-deploy HTML on client-router navigation, so the user must MANUALLY refresh every page to see the change. Concretely: #898 added syntax highlighting to blog code blocks AT SSR. On webjs.dev, opening a blog post after the deploy shows no highlighting until a manual refresh, and navigating to the next post shows no highlighting again, and so on for every post.
The client router already HAS deploy detection:
applySwap(@webjsdev/core/src/router-client.js~L2237-2268) compares the response'sX-Webjs-Buildheader (incomingBuild) against the current page'sdata-webjs-build, and hard-reloads (location.assign) on a mismatch (the Turbotracked_element_mismatchpattern). The gap is the BUILD ID itself: it is derived ONLY from the importmap hash (importMapHash()in@webjsdev/server/src/importmap.jsL191, set at L68/137/161 asdigestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })))), surfaced aspublishedBuildId(). An SSR-only change (syntax highlighting, a template tweak, a copy edit, a new server-rendered feature) adds no importmap entry, so the importmap hash is byte-identical across the deploy, the build id does NOT change,incomingBuild === currentBuild, no mismatch is detected, and no hard reload fires. Worse, prefetch is on by default (device-adaptive), so pages the user hovered or scrolled near BEFORE the deploy are cached in the prefetch cache as pre-deploy HTML and served verbatim on the next nav.So the deploy is invisible to the client whenever it did not touch the importmap, which is the common case for content and SSR changes.
Design / approach
Two parts:
Make the published build id reflect the actual deploy, not just the importmap. Fold a per-DEPLOY fingerprint into
publishedBuildId()so ANY deploy bumps it. Sources, in precedence order: an explicitWEBJS_BUILD_IDenv var (deployer-set), else a detected platform deploy id (Railway'sRAILWAY_GIT_COMMIT_SHA/RAILWAY_DEPLOYMENT_ID, Vercel'sVERCEL_GIT_COMMIT_SHA, a genericGIT_COMMIT/SOURCE_COMMIT), else (last resort) the process boot id / start time so a restart-per-deploy platform still bumps it. Keep it STABLE within a single running process (compute once at boot), so it is not per-request (which would infinite-reload). When no deploy id is available, fall back to today's importmap-hash behavior so nothing regresses. The build id becomeshash(importmapHash + deployFingerprint)(or just the deploy fingerprint when present), keeping it a short opaque string.On a detected build change, clear the WHOLE client cache, not just the current nav. Today
applySwaphard-reloads the current navigation on mismatch, but the snapshot cache (snapshotCache, router-client.js ~L752) and prefetch cache (prefetchCache) may still hold pre-deploy HTML for OTHER urls. A hard reload of the current page resets JS state so those in-memory caches are cleared incidentally, but the risk is a stale-build prefetch served BEFORE the mismatch-triggering nav. So: whenincomingBuilddiffers from the boot build (including on a prefetch response), evict every snapshot + prefetch entry (reuserevalidate()with no arg, router-client.js L314, which doessnapshotCache.clear(); prefetchCache.clear()), and drop any prefetched fragment whose storedbuilddiffers from the current page build before serving it (prefetch entries already storebuild, router-client.js ~L1287/L1635). That guarantees no pre-deploy HTML is ever applied via a soft nav.Prior art: Turbo's
tracked_element_mismatchplus its build-tracking. WebJs already mirrors the mismatch-reload; this issue extends the SIGNAL (the id) to cover SSR-only deploys and hardens the cache eviction.Implementation notes (for the implementing agent)
packages/server/src/importmap.js: the three_importMapHash = digestHex(...)sites (L68/137/161) andpublishedBuildId()/importMapHash()(L190-192). Add adeployFingerprint()helper (readWEBJS_BUILD_ID/ platform envs / boot fallback, computed once) and fold it into the published id. Confirm EVERY id consumer stays consistent:build-info.jsL45 (/__webjs/version),html-cache.jsL141 (the Redis HTML-cache key, which SHOULD include the deploy id so a deploy busts the server HTML cache too), thedata-webjs-buildattr emit (importmap.js~L166) and theX-Webjs-Buildresponse header.packages/core/src/render-client.js/router-client.js: thedata-webjs-buildread inapplySwap(~L2263), the prefetchbuildcapture (~L1287) and use (~L1635), andrevalidate()(L309). Add the "build changed -> clear all caches + drop stale-build prefetches" logic. Keep the existing "empty/absent id on either side never reloads" guard (the warmup-window protection called out at router-client.js ~L1991) so a cold or runtime-first-boot server does not wipe a half-filled form.trackedReloadSignatureat router-client.js already guards against).html-cache.jskeys cached HTML bypublishedBuildId() || 'nobuild'(L141); folding the deploy id in means a deploy correctly invalidates the server HTML cache too, which is desirable, but verify it does not thrash a multi-instance deploy where instances briefly disagree on the id (they converge; a brief cache miss is fine).asset-hash.jsL10 explicitly notes the importmap build id does NOT change on an app-file edit; that comment (and any behavior relying on it) may need updating.publishedBuildIdplus the header/attr emit ride the SSR + listener path; add atest/bun/*.mjsassertion that a changedWEBJS_BUILD_IDchanges the emitted id on both runtimes.packages/stays plain JS + JSDoc. Progressive enhancement: the hard reload is a client-only enhancement; a JS-off user always gets fresh SSR HTML anyway.packages/server/test/importmap/*,packages/server/test/*build*) for the id derivation + precedence; browser/e2e (test/e2e/*) for the headline (a simulated build-id bump on the server triggers a client hard reload on the next nav and evicts stale prefetches); Bun parity script. Docs:agent-docs/advanced.md(client router deploy detection),agent-docs/configuration.mdplusagent-docs/built-ins.md(the newWEBJS_BUILD_IDenv plus platform auto-detect), the deployment docs-site page.Acceptance criteria
WEBJS_BUILD_IDor a detected platform/git id).