fix: reflect app/framework deploys on client nav via content signals#900
Conversation
An SSR-only deploy (syntax highlighting, a template tweak, a copy edit) leaves the importmap byte-identical, so the published build id (importmap hash) never changed. The client router compares that id across navigations to detect a deploy, so it saw no change and kept serving stale pre-deploy HTML on soft nav until a manual refresh, per page. Fold a per-deploy fingerprint into publishBuildId when one is available: WEBJS_BUILD_ID (deployer-set, e.g. the git SHA) or a detected platform commit id (Railway / Vercel / Render / generic GIT_COMMIT). All instances of one deploy share the value, so a rolling/multi-instance deploy does not flap; deliberately no per-process boot-id fallback (it would differ per instance and hard-reload in a loop behind a load balancer). With no fingerprint the id is the importmap hash exactly as before, and the empty-until-final warmup guard still holds. Closes #899
When the client router detects a build-id mismatch (a deploy), every URL-keyed snapshot and speculative prefetch was captured on the old deploy, so it is stale pre-deploy HTML. Evict both caches at the mismatch, so no stale fragment is applied on a later soft nav, including when the one-shot infinite-reload guard bails to a partial swap instead of a full reload.
…urces Add a Bun parity script proving the deploy fingerprint folds into the published build id (and thus the X-Webjs-Build header) identically on Node and Bun, plus a determinism assertion that there is no per-process boot-id fallback. Document the two env sources the code reads that the prose omitted (RAILWAY_DEPLOYMENT_ID, SOURCE_VERSION) in the deployFingerprint JSDoc and agent-docs/advanced.md.
vivek7405
left a comment
There was a problem hiding this comment.
Review: deploy-fingerprint fix, two gaps closed
The core mechanism holds up: the fold only touches publishedBuildId (importMapHash stays the per-asset fingerprint), the empty-until-final warmup guard is preserved, the sanitization is header-injection-safe, and the client eviction sits before the infinite-reload guard bail so a partial-swap fallback can't pull a stale fragment. Two things I fixed: the code reads two deploy envs the docs never listed, and the header-emission path had no Bun parity coverage.
A prefetch captured before a deploy stores the old build id, which equals the still-old current-page id, so the applySwap mismatch guard alone cannot tell it is stale and would soft-swap it. Detect the deploy at PREFETCH-fetch time: a prefetch response carries the server's current build, so when it differs from the page's build a deploy has landed and every earlier snapshot/prefetch is stale. Evict them there, well before the click (a hover/viewport prefetch fires early), so a click on a previously-prefetched link re-fetches fresh and hard-reloads. One residual window is inherent (a stale prefetch consumed before any fresh response reveals the new id is served once); a client cannot detect a deploy from purely-cached navigation without contacting the server. The docs now state the coverage and this residual accurately instead of implying full closure.
vivek7405
left a comment
There was a problem hiding this comment.
Review: the client eviction had a real residual, now shrunk and documented honestly
Good catch on the prefetch case. The nav-time eviction only fires once a fresh response reveals the new id, so a page prefetched BEFORE the deploy (old id == still-old page id) would soft-swap stale on the first click. I added deploy detection at prefetch-fetch time, which fires eagerly on hover/viewport and evicts the stale caches before the click, so a click on a previously-prefetched link re-fetches fresh and hard-reloads. One window is inherent (a stale prefetch consumed before ANY fresh response is seen serves once) since a client can't detect a deploy from purely-cached navigation, and the docs now say so instead of implying full closure.
vivek7405
left a comment
There was a problem hiding this comment.
Review: prefetch-time eviction holds up
Went back over the prefetch-time detection and its interaction with the nav-time path. The guard needs both ids present so a redirect, an X-Webjs-Have partial (no build header), and the warmup empty id all skip it. The clear runs before the fresh entry is stored, so it can't wipe what it is about to store. A concurrent post-deploy prefetch can wipe another post-deploy entry, but that is harmless: any post-deploy prefetch has the new id vs the still-old page, so a click on it hard-reloads via the mismatch guard rather than soft-swapping, so dropping it just re-fetches. Nothing to change.
…rfaces The pre-existing build-id assertions (dev-handler + importmap tests) pin the bare 64-hex importmap-hash format and publishedBuildId()===importMapHash(), so once a deploy env var (GIT_COMMIT on a platform runner, WEBJS_BUILD_ID, etc.) is present they would break, since #899 folds the fingerprint into the published id. Clear the deploy envs in those files' setup so they are deterministic wherever the suite runs. Also sync the doc surfaces the fingerprint touches that the earlier commits missed: packages/server/AGENTS.md (the build-id-stability section no longer claims importmap-hash-only), the docs-site deployment page (the WEBJS_BUILD_ID env + the /__webjs/version build field), and agent-docs/configuration.md.
vivek7405
left a comment
There was a problem hiding this comment.
Review: server side + regressions, three drift issues fixed
Rotated onto the server side and the doc surfaces. No runtime consumer of the id parses it by length or splits on the dot (applySwap does string equality, html-cache uses it as an opaque key, build-info returns it verbatim), so the longer <hash>.<fp> id is safe. Three things needed fixing: the pre-existing build-id tests pinned the bare 64-hex format and were not isolated from the deploy envs, so they would break on a runner that exports GIT_COMMIT; and two doc surfaces the fingerprint touches were left stale (the per-package AGENTS.md build-id section and the docs-site deployment page + its env-var list).
importMapHash() is the live importmap hash computed fingerprint:false, so it is independent of the per-file ?v asset hashes (which asset-hash.js owns), not the ?v input itself. Reword the reassurance accordingly.
vivek7405
left a comment
There was a problem hiding this comment.
Review: full-PR sweep, one doc wording fix
Went over the whole thing end to end one more time: server id derivation through the header, client detection, both eviction points, the tests and their counterfactuals, the CI step, and every doc surface for cross-consistency. It all agrees. One wording slip in the AGENTS.md note called importMapHash() the ?v asset-fingerprint input, but it is the live importmap hash computed fingerprint:false and independent of the per-file ?v hashes (asset-hash.js owns those). Fixed the wording.
vivek7405
left a comment
There was a problem hiding this comment.
Review: final pass, clean
Confirmed the importMapHash wording now matches the code (computed fingerprint:false, independent of the asset-hash.js ?v hashes), the fingerprint precedence list reads identically in all five places it appears, the residual-window caveat is consistent, and there are no banned glyphs or stray debug in the added lines. Nothing left to change.
|
Root cause confirmed in a real browser (not assumed) Before reshaping this, I proved the exact mechanism end to end. Two independent checks:
So the stale page comes from the client-router in-memory prefetch/snapshot cache, and the client has no signal to drop it. That is the whole bug, and it is why a hard-refresh of one page did not fix the others (each tab/cache holds its own stale copy). Consequence for this PR: the env-var |
…nv var Replaces the rejected env-var (WEBJS_BUILD_ID) approach with an automatic, content-based deploy signal, the no-build Rails+Turbo model where the served content IS the version. Root cause (reproduced in a browser): the client-router in-memory prefetch/snapshot cache serves a pre-deploy page with no network fetch, and the client's only deploy signal is the importmap hash, which an app-only or SSR-only deploy does not change, so it never evicts. Two content signals now ride every SSR response (attribute + header, so partial X-Webjs-Have responses carry them), each with the right response: - Reload signal (data-webjs-build / X-Webjs-Build) = importmap hash folded with the installed @webjsdev/core version. Changes on a vendor pin or a core release (browser code the running page cannot hot-swap) -> HARD RELOAD. - Evict signal (data-webjs-src / X-Webjs-Src) = a content hash of ALL app source (the module-graph seen set, including server-only .server.ts that a browser-bound set misses) folded with the installed @webjsdev/server version. Changes on an app-source change or a server-framework release (SSR output moved, no new browser code) -> EVICT the snapshot/prefetch caches and advance the page's reference id, a soft re-fetch, NO reload. Server: setCoreInstall captures the core version; publishBuildId folds it; module-graph exposes the full source set via seenFilesFor; dev.js computes the app-source id (raw per-file byte digests + the server version) in the analysis; ssr.js + importMapTag emit X-Webjs-Src / data-webjs-src. The #318 html-cache fingerprint is untouched. Client: applySwap gains a src-mismatch branch (evict, no reload) alongside the existing build-mismatch reload; the prefetch entry carries src and the prefetch-time detection evicts on a src change too. Framework updates reflect once the app installs the new @webjsdev/* version (governed by its dependency range/lockfile); the signal detects the deployed version, it does not pull the update. Closes #899
|
Design: why two signals with different responses, and why content over an env var Two decisions worth recording. Content hash, not an env var. The first cut folded a Two signals, because the two kinds of deploy need opposite handling. A change to browser-shipped framework code (core, vendor) cannot be hot-swapped by the running page (the importmap is committed at load), so it MUST hard-reload. But an app-source or server-framework change only moved the SSR output; the running browser code is fine, so a hard reload is an over-correction that wipes scroll and form input. That is exactly the deliberate One subtlety the browser test caught: the importmap |
…intent git add -A had staged packages/server/node_modules (a local test symlink to the primary checkout, an absolute machine path) into the feature commit; untrack it (.gitignore's node_modules/ misses a symlink named node_modules). Also document why the prefetch-time src-evict deliberately does not advance the page's data-webjs-src (applySwap stays the single authority).
vivek7405
left a comment
There was a problem hiding this comment.
Review: content-signal reshape, one real slip and one wrinkle
The two-signal mechanism holds up end to end: core version captured before publishBuildId, appSourceId final before the first response, the empty-id guards on both sides, build-mismatch still hard-reloads while src-mismatch evicts without reload, and importMapHash / the #318 fingerprint / asset hashing untouched. Two things: the final feature commit had accidentally staged packages/server/node_modules (a local test symlink to an absolute machine path in the primary checkout), which git's node_modules/ ignore misses because it is a symlink, so it would land in the repo on merge. Untracked and removed. And the prefetch-time evict does not advance the page reference id, so repeated prefetches in the narrow pre-first-nav window re-clear the caches until the first real nav settles it. That is benign and converges, so I left the behavior and documented the intent (applySwap stays the single authority).
vivek7405
left a comment
There was a problem hiding this comment.
Review: final sweep, clean
Confirmed the diff carries no node_modules / dist / throwaway script / stray diagnostic, no banned prose glyphs in the added lines or commit messages, and that the two-signal story reads identically across advanced.md, server/AGENTS.md, configuration.md, and the deployment page with no leftover env-var wording. The applySwap src-evict is mutually exclusive with the reload path and correctly guarded. Nothing left to change.
Closes #899
Root cause (reproduced in a browser, not assumed)
After an app-only deploy to webjs.dev (PR #898, syntax-highlighting blog code at SSR, which touched only website source), blog posts kept showing the pre-highlight version and each had to be hard-refreshed. Evidence:
ETag,Cache-Control: max-age=0,cf-cache-status: DYNAMIC(Cloudflare passes to origin). The server always serves fresh HTML.X-Webjs-Build(the importmap hash) does not change on an app/SSR deploy, so the client has no signal to evict.The fix (automatic, content-based, no env var)
The no-build Rails+Turbo model: the served content IS the version, hashed at runtime. Two signals ride every SSR response (attribute + header, so partial responses carry them), each with the right response:
data-webjs-build= importmap hash folded with the installed@webjsdev/coreversion. Changes on a vendor pin or a core release (browser code the running page can't hot-swap) → hard reload.data-webjs-src= a content hash of ALL app source (the module-graphseenset, including server-only.server.tsa browser-bound set misses) folded with the installed@webjsdev/serverversion. Changes on an app-source change or a server-framework release (SSR output moved, no new browser code) → evict the snapshot/prefetch caches + advance the page's reference id, a soft re-fetch, no reload.This supersedes the earlier
WEBJS_BUILD_IDenv-var commits on this branch (removed): no build step means nothing to bake an id into; the source content is the version. Framework updates reflect once the app installs the new@webjsdev/*version (governed by the app's dependency range/lockfile); the signal detects the deployed version, it does not pull the update.Test plan
data-webjs-srcadvances)seenFilesForincludes server-only.server.tsand excludes node_modules/.webjs/public/dotfiles;appSourceIdderivation deterministic + distinct from the build id; the build id now<hash>.c<coreVersion>applySwapsrc-mismatch evicts + does NOT reload; build-mismatch still reloads; the prefetch entry carriessrcand a src-mismatched prefetch evicts (157 pass)node:cryptodigest) derives the identical known digest on Node and BunX-Webjs-Build+X-Webjs-Srcemitted, no broken preloads; blog e2e covered by CI.server.tsmovesX-Webjs-SrcwhileX-Webjs-Buildstays putDocs
agent-docs/advanced.md(the two-signal cross-deploy section, rewritten),packages/server/AGENTS.md,agent-docs/configuration.md,docs/app/docs/deployment/page.ts(no env var; automatic deploy detection).