Skip to content

Commit 0ff0abd

Browse files
authored
fix(webapp): recover from stale /build assets via a bounded reload (#4282)
## Problem The webapp's HTML references content-hashed `/build` assets, and each running instance contains exactly one build and returns 404 for asset hashes it doesn't have. During a rolling deploy a client can hold HTML from one build while a request for one of its assets is served by an instance on a different build → missing styles or a failed chunk load. ## What this does On a `/build` stylesheet/script/chunk load failure, the client does a **bounded full-document reload** (at most 2 per 5 minutes, tracked in `sessionStorage`) so the page reloads onto a single consistent build. That's the whole mechanism — no polling, no `fetch` interception, no blocking overlay, no form snapshotting. - `apps/webapp/app/components/StaleAssetRecovery.tsx` — authored as a typed, lint-checked function and serialized to an inline script via `.toString()` (so the logic is real, reviewable code, not an opaque string), injected before `<Links />`, production only. - Detection: capture-phase `error` listener for `<link>`/`<script>`/modulepreload failures under `/build/`, plus an `unhandledrejection` guard for dynamic-import failures. - Guards: once-per-page re-entrancy guard, the bounded reload budget, and a `navigator.onLine` check so it never reloads into an offline error page. - Unit tests in `StaleAssetRecovery.test.ts`. ## Relationship to #4260 Replaces the recovery introduced in #4260 (reverted in #4280) with a much smaller, reload-only approach — the previous version intercepted `fetch` and could turn a data request into a navigation, and showed a full-screen overlay on any asset error; this drops both. ## `/build-version` compatibility shim `apps/webapp/server.ts` adds a tiny `GET /build-version` endpoint (build id only, `no-store`). A previously-deployed client build polls it after an asset failure and reloads once it sees a newer build, so those older tabs recover in one reload instead of getting stuck. Temporary — safe to remove once older clients have cycled out. It deliberately does **not** re-add an `X-Build-Id` response header. ## Also Restores the `.server-changes` writing guidance in `.claude/rules/server-apps.md` (reverted alongside #4260). ## Self-hosting note Recovery is most reliable when your load balancer keeps a client on one instance for the duration of a deploy (short session stickiness) — the reload then lands on a consistent build in one hop.
1 parent 73d966a commit 0ff0abd

7 files changed

Lines changed: 182 additions & 1 deletion

File tree

.claude/rules/server-apps.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ area: webapp
1414
type: fix
1515
---
1616
17-
Brief description of what changed and why.
17+
Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically.
1818
EOF
1919
```
2020

2121
- **area**: `webapp` | `supervisor`
2222
- **type**: `feature` | `fix` | `improvement` | `breaking`
2323
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
24+
25+
The body ships **verbatim in user-facing release notes**. Keep it to 1–2 short sentences, non-technical, written for a dashboard user: describe what changed for them, never the implementation (no header names, endpoints, middleware, storage mechanisms, internal tools). See `.server-changes/README.md` for full guidance.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix pages occasionally loading unstyled or failing to load during a deploy. The dashboard now reloads automatically to recover.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { staleAssetRecoveryScript } from "./StaleAssetRecovery";
4+
5+
// Each staleAssetRecoveryScript() call models a fresh page load: it reads the shared
6+
// sessionStorage budget and returns its own `recover`. We drive recover() directly rather
7+
// than dispatching resource-error events, so accumulated window listeners never fire.
8+
describe("staleAssetRecoveryScript", () => {
9+
let reload: ReturnType<typeof vi.fn>;
10+
11+
beforeEach(() => {
12+
sessionStorage.clear();
13+
reload = vi.fn();
14+
vi.stubGlobal("location", { reload });
15+
vi.stubGlobal("navigator", { onLine: true });
16+
});
17+
18+
afterEach(() => {
19+
vi.unstubAllGlobals();
20+
vi.restoreAllMocks();
21+
});
22+
23+
it("reloads on a recovery", () => {
24+
staleAssetRecoveryScript().recover();
25+
expect(reload).toHaveBeenCalledTimes(1);
26+
});
27+
28+
it("reloads only once per page even if several assets fail (re-entrancy guard)", () => {
29+
const { recover } = staleAssetRecoveryScript();
30+
recover();
31+
recover();
32+
recover();
33+
expect(reload).toHaveBeenCalledTimes(1);
34+
});
35+
36+
it("stops reloading once the budget is spent across reloads", () => {
37+
staleAssetRecoveryScript().recover(); // reload 1
38+
staleAssetRecoveryScript().recover(); // reload 2
39+
staleAssetRecoveryScript().recover(); // budget spent -> no reload
40+
expect(reload).toHaveBeenCalledTimes(2);
41+
});
42+
43+
it("does not reload when offline", () => {
44+
vi.stubGlobal("navigator", { onLine: false });
45+
staleAssetRecoveryScript().recover();
46+
expect(reload).not.toHaveBeenCalled();
47+
});
48+
49+
it("does not reload when sessionStorage is unavailable", () => {
50+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
51+
throw new Error("blocked");
52+
});
53+
staleAssetRecoveryScript().recover();
54+
expect(reload).not.toHaveBeenCalled();
55+
});
56+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Recovers from a rolling deploy rotating the content-hashed /build assets out from
2+
// under a page. Each image serves only its own build and hard-404s unknown hashes, so
3+
// a client can request a hash the serving replica doesn't have and get missing styles
4+
// or a failed asset load. On such a /build load failure we do a bounded full document
5+
// reload: the fresh document (and, under sticky routing, all of its assets) lands on a
6+
// single live build, so the asset resolves. Bounded via sessionStorage so it can never
7+
// loop; when the budget is spent it stops rather than reloading forever.
8+
//
9+
// Deliberately minimal — no fetch interception, no build-version polling, no server
10+
// build-id contract, no form snapshot, no blocking overlay.
11+
12+
// The recovery logic runs as an inline <script> injected before <Links /> (see the
13+
// component below), so it must execute before the app bundle and before the stylesheet
14+
// can fail to load. It is authored as a normal, type-checked and lint-checked function
15+
// and serialized with .toString() at render time — NOT hand-written into a string — so
16+
// the logic is real code the compiler and linter can see. Because it is serialized, it
17+
// must stay fully self-contained: no imports, no references to module scope, and plain
18+
// ES that the bundler won't rewrite to reach a hoisted helper. It returns its `recover`
19+
// closure purely so the unit test can drive the logic directly (the inline IIFE that
20+
// runs in the browser ignores the return value).
21+
export function staleAssetRecoveryScript() {
22+
var KEY = "trigger:assetReload";
23+
var MAX_RELOADS = 2;
24+
var WINDOW_MS = 300000;
25+
var recovering = false;
26+
27+
function budgetAllows() {
28+
try {
29+
var raw = sessionStorage.getItem(KEY);
30+
var state = raw ? (JSON.parse(raw) as { n: number; t: number }) : { n: 0, t: 0 };
31+
if (Date.now() - state.t > WINDOW_MS) state = { n: 0, t: 0 };
32+
if (state.n >= MAX_RELOADS) return false;
33+
sessionStorage.setItem(KEY, JSON.stringify({ n: state.n + 1, t: Date.now() }));
34+
return true;
35+
} catch {
36+
// Storage blocked (private mode / quota): can't bound reloads, so don't auto-reload.
37+
return false;
38+
}
39+
}
40+
41+
function recover() {
42+
// One recovery per page: a broken load fails several /build assets at once and each
43+
// fires its own error event before location.reload() commits — without this guard a
44+
// single incident would burn the entire reload budget.
45+
if (recovering) return;
46+
recovering = true;
47+
// Don't reload into the browser's offline error page.
48+
if (navigator.onLine === false) return;
49+
if (budgetAllows()) location.reload();
50+
}
51+
52+
// Non-bubbling resource load failures (stylesheet, modulepreload, entry <script>) at
53+
// document load — the failure class nothing else covers. Capture phase is required.
54+
window.addEventListener(
55+
"error",
56+
function (event) {
57+
var el = event.target as Element | null;
58+
if (!el || typeof el.tagName !== "string") return; // window/global errors have no tagName
59+
var url =
60+
el.tagName === "LINK"
61+
? (el as HTMLLinkElement).href
62+
: el.tagName === "SCRIPT"
63+
? (el as HTMLScriptElement).src
64+
: null;
65+
if (url && url.indexOf("/build/") !== -1) recover();
66+
},
67+
true
68+
);
69+
70+
// Raw dynamic import() failures in app code. (Remix reloads its own route chunks, so
71+
// that path rarely reaches here.) The message URL isn't reliable cross-browser, so
72+
// match the chunk-load error shape; the once-guard + bounded budget make a rare stray
73+
// reload harmless.
74+
window.addEventListener("unhandledrejection", function (event) {
75+
var message = (event.reason && event.reason.message) || "";
76+
if (
77+
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
78+
) {
79+
recover();
80+
}
81+
});
82+
83+
return { recover };
84+
}
85+
86+
export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) {
87+
if (!isProduction) {
88+
return null;
89+
}
90+
91+
return (
92+
<script dangerouslySetInnerHTML={{ __html: `(${staleAssetRecoveryScript.toString()})()` }} />
93+
);
94+
}

apps/webapp/app/entry.server.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export default function handleRequest(
5454
) {
5555
const url = new URL(request.url);
5656

57+
// Stale documents reference /build asset hashes that 404 after a deploy —
58+
// always revalidate HTML. Route-set headers win.
59+
if (!responseHeaders.has("Cache-Control")) {
60+
responseHeaders.set("Cache-Control", "no-cache");
61+
}
62+
5763
if (url.pathname.startsWith("/login")) {
5864
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
5965
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");

apps/webapp/app/root.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ToastMessage } from "~/models/message.server";
77
import { commitSession, getSession } from "~/models/message.server";
88
import tailwindStylesheetUrl from "~/tailwind.css";
99
import { RouteErrorDisplay } from "./components/ErrorDisplay";
10+
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
1011
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
1112
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
1213
import { Toast } from "./components/primitives/Toast";
@@ -18,6 +19,11 @@ import { getUser } from "./services/session.server";
1819
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
1920
import { appEnvTitleTag } from "./utils";
2021

22+
// Derived here (not inside StaleAssetRecovery) so the shared component takes
23+
// the flag as a prop. NODE_ENV is statically replaced in browser bundles, and
24+
// the ErrorBoundary can't rely on loader data.
25+
const isProduction = process.env.NODE_ENV === "production";
26+
2127
export const links: LinksFunction = () => {
2228
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
2329
};
@@ -99,6 +105,7 @@ export function ErrorBoundary() {
99105
<head>
100106
<meta charSet="utf-8" />
101107

108+
<StaleAssetRecovery isProduction={isProduction} />
102109
<Meta />
103110
<Links />
104111
</head>
@@ -125,6 +132,7 @@ export default function App() {
125132
<>
126133
<html lang="en" className="h-full" data-theme="dark">
127134
<head>
135+
<StaleAssetRecovery isProduction={isProduction} />
128136
<Meta />
129137
<Links />
130138
</head>

apps/webapp/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
134134
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
135135

136136
if (process.env.HTTP_SERVER_DISABLED !== "true") {
137+
// Back-compat shim: a previously-deployed client build polls this endpoint after a
138+
// /build asset 404 and reloads once it reports a newer build id, letting those older
139+
// tabs recover in a single reload. Temporary — safe to remove once older clients have
140+
// churned out. Deliberately does NOT set an X-Build-Id response header.
141+
app.get("/build-version", (_req, res) => {
142+
res.set("Cache-Control", "no-store");
143+
res.json({ version: build.assets.version });
144+
});
145+
137146
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
138147
const wss: WebSocketServer | undefined = build.entry.module.wss;
139148
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;

0 commit comments

Comments
 (0)