Skip to content

Commit 6d78b12

Browse files
committed
feat(webapp): make stale-asset recovery version-aware
Instead of blindly reloading on a /build asset failure, the recovery script now polls a new /build-version endpoint (no-store) with backoff and reloads only once the server reports a different build than the one the page was rendered with (window.__remixManifest.version). If the versions never diverge - the asset failed for some other reason - or the reload budget is spent, it shows a manual-reload banner instead of leaving a dead page. Turns a speculative reload into a deterministic wait for a compatible deployment state.
1 parent 671b9ac commit 6d78b12

3 files changed

Lines changed: 114 additions & 24 deletions

File tree

.server-changes/stale-deploy-asset-recovery.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
Fix intermittent unstyled/broken pages during rolling deploys, caused by stale HTML requesting `/build` asset hashes that 404 on the new image. Documents now default to `Cache-Control: no-cache` so browsers always revalidate HTML, and an inline script reloads the page (max twice) when a `/build` asset fails to load.
6+
Fix intermittent unstyled/broken pages during rolling deploys, caused by stale HTML requesting `/build` asset hashes that 404 on the new image. Documents now default to `Cache-Control: no-cache` so browsers always revalidate HTML. On a `/build` asset failure, an inline script polls the new `/build-version` endpoint with backoff and reloads only once the server reports a different build than the page was rendered with; if versions never diverge or the reload budget is spent, it shows a manual-reload banner instead of leaving a dead page.

apps/webapp/app/components/StaleAssetRecovery.tsx

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,120 @@
1-
// Self-heals stale-deploy asset failures: when a /build asset 404s (the
2-
// client holds HTML from a previous build), reload at most twice, 30s apart.
3-
// Must render before <Links /> so the listener precedes the stylesheet.
1+
// Self-heals stale-deploy asset failures. When a /build asset fails to load
2+
// (the client holds HTML from a previous build), poll /build-version with
3+
// backoff and reload only once the server actually reports a different build
4+
// than the one this page was rendered with (window.__remixManifest.version,
5+
// inlined by Remix into every document). If the versions never diverge (the
6+
// asset failed for some other reason) or the reload budget is spent, show a
7+
// manual-reload banner instead of leaving a dead page. Must render before
8+
// <Links /> so the listener precedes the stylesheet.
49
const script = `(function () {
5-
var KEY = "trigger:assetReload";
6-
var MIN_INTERVAL = 30000;
10+
var KEY = "trigger:assetRecovery";
11+
var MAX_RELOADS = 2;
712
var RESET_AFTER = 300000;
8-
var MAX_ATTEMPTS = 2;
9-
var scheduled = false;
10-
function reload() {
11-
if (scheduled) return;
13+
var CHECK_DELAYS = [0, 2000, 4000, 8000, 15000, 30000];
14+
var recovering = false;
15+
16+
function readState() {
17+
try {
18+
return JSON.parse(sessionStorage.getItem(KEY) || "{}");
19+
} catch (e) {
20+
return null;
21+
}
22+
}
23+
24+
function writeState(state) {
1225
try {
13-
var state = JSON.parse(sessionStorage.getItem(KEY) || "{}");
14-
var elapsed = Date.now() - (state.t || 0);
15-
var attempts = elapsed > RESET_AFTER ? 0 : state.n || 0;
16-
if (attempts >= MAX_ATTEMPTS) return;
17-
// Failures fire once, at page load — delay the retry instead of
18-
// dropping it, so an in-progress deploy gets time to finish.
19-
var wait = Math.max(0, MIN_INTERVAL - elapsed);
20-
sessionStorage.setItem(KEY, JSON.stringify({ t: Date.now() + wait, n: attempts + 1 }));
21-
scheduled = true;
22-
setTimeout(function () {
23-
location.reload();
24-
}, wait);
26+
sessionStorage.setItem(KEY, JSON.stringify(state));
27+
return true;
2528
} catch (e) {
29+
return false;
30+
}
31+
}
32+
33+
function showBanner() {
34+
if (!document.body) {
35+
document.addEventListener("DOMContentLoaded", showBanner);
2636
return;
2737
}
38+
if (document.getElementById("stale-asset-banner")) return;
39+
var banner = document.createElement("div");
40+
banner.id = "stale-asset-banner";
41+
banner.style.cssText =
42+
"position:fixed;top:0;left:0;right:0;z-index:2147483647;display:flex;gap:12px;align-items:center;justify-content:center;padding:10px 16px;background:#121317;color:#d7d9dd;font:14px/1.4 system-ui,sans-serif;box-shadow:0 1px 4px rgba(0,0,0,0.4)";
43+
var text = document.createElement("span");
44+
text.textContent = "This page failed to load properly, possibly due to an update being deployed.";
45+
var button = document.createElement("button");
46+
button.textContent = "Reload";
47+
button.style.cssText =
48+
"border:0;border-radius:4px;padding:5px 14px;background:#6366f1;color:#fff;font:inherit;cursor:pointer";
49+
button.onclick = function () {
50+
location.reload();
51+
};
52+
banner.appendChild(text);
53+
banner.appendChild(button);
54+
document.body.appendChild(banner);
55+
}
56+
57+
function reloadFor(serverVersion) {
58+
var state = readState();
59+
if (!state) return showBanner();
60+
if (Date.now() - (state.t || 0) > RESET_AFTER) state = {};
61+
// One reload per observed server version, MAX_RELOADS total: a page that
62+
// is still broken after reloading for this build shows the banner
63+
// instead of reloading again.
64+
if (state.v === serverVersion || (state.reloads || 0) >= MAX_RELOADS) return showBanner();
65+
if (!writeState({ v: serverVersion, reloads: (state.reloads || 0) + 1, t: Date.now() })) {
66+
return showBanner();
67+
}
68+
location.reload();
69+
}
70+
71+
function check(attempt) {
72+
fetch("/build-version", { cache: "no-store" })
73+
.then(function (response) {
74+
return response.json();
75+
})
76+
.then(function (data) {
77+
var mine = window.__remixManifest && window.__remixManifest.version;
78+
if (data && data.version && mine && data.version !== mine) {
79+
reloadFor(data.version);
80+
} else {
81+
scheduleNext(attempt);
82+
}
83+
})
84+
.catch(function () {
85+
scheduleNext(attempt);
86+
});
87+
}
88+
89+
function scheduleNext(attempt) {
90+
var next = attempt + 1;
91+
if (next >= CHECK_DELAYS.length) return showBanner();
92+
setTimeout(function () {
93+
check(next);
94+
}, CHECK_DELAYS[next]);
95+
}
96+
97+
function recover() {
98+
if (recovering) return;
99+
recovering = true;
100+
// __remixManifest is set by an inline script near the end of body; wait
101+
// for the document to finish parsing before comparing versions.
102+
if (document.readyState === "loading") {
103+
document.addEventListener("DOMContentLoaded", function () {
104+
check(0);
105+
});
106+
} else {
107+
check(0);
108+
}
28109
}
110+
29111
window.addEventListener(
30112
"error",
31113
function (event) {
32114
var el = event.target;
33115
if (!el || el === window) return;
34116
var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null;
35-
if (url && url.indexOf("/build/") !== -1) reload();
117+
if (url && url.indexOf("/build/") !== -1) recover();
36118
},
37119
true
38120
);
@@ -42,7 +124,7 @@ const script = `(function () {
42124
typeof message === "string" &&
43125
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
44126
) {
45-
reload();
127+
recover();
46128
}
47129
});
48130
})();`;

apps/webapp/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ 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+
// Reports the build this replica is running. The client-side stale-asset
138+
// recovery polls it after a /build 404 and reloads only once the server
139+
// reports a different build than the one the page was rendered with.
140+
app.get("/build-version", (_req, res) => {
141+
res.set("Cache-Control", "no-store");
142+
res.json({ version: build.assets.version });
143+
});
144+
137145
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
138146
const wss: WebSocketServer | undefined = build.entry.module.wss;
139147
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;

0 commit comments

Comments
 (0)