mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
A tab left open across a deploy keeps running the previous build's JavaScript indefinitely. index.html is fetched only on a full page load, all later navigation is client-side, and hashed bundles are served `immutable`, so nothing reveals that the code is stale. This produced a false-positive bug report where two correctly-deployed fixes appeared to be missing. Publishes a build id and offers a reload when the running document falls behind. The toast never reloads on its own; the only automatic reload is recovery from a chunk that no longer exists. Build id derivation ------------------- The obvious approach — hash the emitted asset filenames, which already embed content hashes — does not work: Bun's minified identifier naming is not deterministic. Building an unchanged tree twice produces byte-different output roughly one run in three (same length, ~100k differing bytes, all of it mangled names). Output hashes therefore move with no source change, which would fire the toast on redeploys of identical code and train people to ignore it. The id is instead derived from the bundle's source inputs, so it changes if and only if something we control changed. Verified stable across eight consecutive builds while the entry hash flipped between both variants. This non-determinism also means two builds of the same commit embed different bytes into the server binary, which is worth addressing separately for reproducible builds. Detection --------- SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React effects policy. SWR does not poll while the document is hidden, so background tabs stay quiet without extra gating. Unknown state on either side — missing meta tag, failed fetch, 503 during a dev rebuild — never produces a prompt. Stylesheet hashing ------------------ Tailwind's output was stable-named and therefore served `no-cache`, letting a tab revalidate into new CSS while running old JS. Tailwind purges unused classes per build, so classes the old bundle still emits could silently lose their styles. It is now content-hashed and moves with the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
54 lines
2.2 KiB
TypeScript
54 lines
2.2 KiB
TypeScript
import { documentBuildId } from "./build-version";
|
|
|
|
const RELOAD_MARKER_PREFIX = "fabro:chunk-reload:";
|
|
|
|
/**
|
|
* Loads a lazily-imported chunk, reloading the page once if it cannot be
|
|
* fetched.
|
|
*
|
|
* Each deploy replaces the served assets and the previous build's hashed
|
|
* filenames stop existing, so a tab open across a deploy can request a chunk
|
|
* that now 404s. Static route imports mean most of the graph is already in
|
|
* memory, but the handful of genuinely lazy imports — the terminal, Graphviz
|
|
* rendering, the file tree — are loaded on demand and can land in that window.
|
|
*
|
|
* A failed chunk means the feature is already broken, so reloading is recovery
|
|
* rather than an interruption. This is the one place the app reloads without an
|
|
* explicit click; the build-version toast never does.
|
|
*/
|
|
export function importChunk<T>(load: () => Promise<T>): Promise<T> {
|
|
return load().catch((error: unknown) => {
|
|
reloadOnceForStaleChunk();
|
|
// Rethrow rather than returning a never-settling promise. The reload
|
|
// normally replaces the document before this surfaces; if it doesn't, an
|
|
// error boundary is a better outcome than a spinner that hangs forever.
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reloads at most once per build.
|
|
*
|
|
* Keyed by build id rather than a bare flag so a tab that recovers from one
|
|
* deploy still has a reload available for the next one. Without the key, a
|
|
* single chunk failure would disarm the backstop for the rest of the session.
|
|
*
|
|
* A module that loads fine but throws while evaluating is indistinguishable
|
|
* here from a missing chunk, so it also spends the reload. The per-build key
|
|
* bounds the cost at one wasted reload, after which the real error surfaces.
|
|
*/
|
|
function reloadOnceForStaleChunk(): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const key = `${RELOAD_MARKER_PREFIX}${documentBuildId() ?? "unknown"}`;
|
|
try {
|
|
if (window.sessionStorage.getItem(key)) return;
|
|
window.sessionStorage.setItem(key, "1");
|
|
} catch {
|
|
// Storage disabled or full. Without a durable marker we can't guarantee
|
|
// "only once", and a reload loop is far worse than a surfaced error.
|
|
return;
|
|
}
|
|
|
|
window.location.reload();
|
|
}
|