Skip to content

fix: reflect app/framework deploys on client nav via content signals#900

Merged
vivek7405 merged 8 commits into
mainfrom
fix/deploy-build-id
Jul 10, 2026
Merged

fix: reflect app/framework deploys on client nav via content signals#900
vivek7405 merged 8 commits into
mainfrom
fix/deploy-build-id

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Live headers rule out HTTP caching: a blog post returns no ETag, Cache-Control: max-age=0, cf-cache-status: DYNAMIC (Cloudflare passes to origin). The server always serves fresh HTML.
  • A scripted Playwright reproduction proved the stale page comes from the client-router in-memory prefetch/snapshot cache (served with ZERO network fetch), and that 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:

  • Reload signal data-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 can't hot-swap) → hard reload.
  • Evict signal data-webjs-src = a content hash of ALL app source (the module-graph seen set, including server-only .server.ts 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 + advance the page's reference id, a soft re-fetch, no reload.

This supersedes the earlier WEBJS_BUILD_ID env-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

  • Root-cause reproduced in a real browser (Playwright, website prod mode); fix re-verified end to end (an appDir source deploy: the soft nav shows fresh content, no hard reload, the page's data-webjs-src advances)
  • Server unit: seenFilesFor includes server-only .server.ts and excludes node_modules/.webjs/public/dotfiles; appSourceId derivation deterministic + distinct from the build id; the build id now <hash>.c<coreVersion>
  • Client unit (node/linkedom): applySwap src-mismatch evicts + does NOT reload; build-mismatch still reloads; the prefetch entry carries src and a src-mismatched prefetch evicts (157 pass)
  • Bun parity: the app-source signal (fs walk + node:crypto digest) derives the identical known digest on Node and Bun
  • Dogfood: website / docs / ui-website boot with X-Webjs-Build + X-Webjs-Src emitted, no broken preloads; blog e2e covered by CI
  • Manual: editing a server-only .server.ts moves X-Webjs-Src while X-Webjs-Build stays put

Docs

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).

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
@vivek7405 vivek7405 self-assigned this Jul 10, 2026
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 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/server/src/importmap.js Outdated
Comment thread test/bun/build-id-deploy.mjs Outdated
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 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/router-client.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/server/test/importmap/importmap.test.js Outdated
Comment thread packages/server/AGENTS.md
Comment thread docs/app/docs/deployment/page.ts Outdated
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 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/server/AGENTS.md Outdated

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vivek7405 vivek7405 marked this pull request as ready for review July 10, 2026 11:24
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Root cause confirmed in a real browser (not assumed)

Before reshaping this, I proved the exact mechanism end to end. Two independent checks:

  1. Live headers on webjs.dev rule out the HTTP layer: a blog post returns no ETag, Cache-Control: max-age=0, and cf-cache-status: DYNAMIC (Cloudflare passes to origin). So the browser and the edge both go to origin every time and get fresh HTML. Edge/SWR, browser cache, and ETag-304 are all out.

  2. A scripted Playwright reproduction against the website in prod mode: load /blog, prefetch a post into the client-router cache, change that post's rendered content and restart the server (a deploy), then click through. Result:

    • X-Webjs-Build is UNCHANGED by the content deploy (the client's only signal is the importmap hash, which an app/SSR change does not move).
    • The click shows the STALE prefetched page, not the deployed content.
    • The click served from the in-memory cache with ZERO network fetch.

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 WEBJS_BUILD_ID approach here is being replaced by an automatic, content-based signal (a hash of the deployed app source, plus the installed framework package versions), which is the no-build-native fix (the served content is the version, the Rails+Turbo model). App/SSR changes evict the client caches softly; core/vendor changes still hard-reload. Rationale and design in the plan; reshape incoming.

…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
@vivek7405 vivek7405 changed the title fix: reflect SSR-only deploys on client navigation (deploy build id) fix: reflect app/framework deploys on client nav via content signals Jul 10, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

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 WEBJS_BUILD_ID env var into the build id. That is how Next/Remix/Astro get a deploy id, but they have a build step to bake it in. WebJs has none, so an env var is manual per-deploy config that is easy to forget. The no-build framework's actual peer is Rails + Turbo, where the content digest of the served assets IS the version (data-turbo-track="reload" on digested <script src>). WebJs already serves per-file ?v content hashes, so it can hash its own served source at runtime. That is the automatic, no-config signal, so the env var came out entirely.

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 importmap-only build id decision in html-cache.js (let app changes propagate via fresh ?v on soft nav, no reload). This PR keeps that: the build id (reload) stays browser-code-scoped, and a second app-source id drives a soft cache-evict instead. So core release -> reload, app/server change -> evict.

One subtlety the browser test caught: the importmap <script> is preserved across soft navs (an importmap cannot be re-registered), so the page's data-webjs-src attribute would keep its old value and re-detect the mismatch on EVERY nav, evicting forever. Fix: advance the attribute in place after a src-evict, so the page settles onto the new deploy (evict once, then cache normally).

…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 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/core/src/router-client.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vivek7405 vivek7405 merged commit 9902b1b into main Jul 10, 2026
10 checks passed
@vivek7405 vivek7405 deleted the fix/deploy-build-id branch July 10, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dogfood: SSR-only deploy not reflected on client nav (build id misses it)

1 participant