Skip to content

Commit bc0e686

Browse files
committed
feat(webapp): never touch a working page; recover with overlay and state restore
Final recovery policy: - All responses carry X-Build-Id. A working page is left alone when a new build exists; when a Remix loader fetch during navigation reveals a different build, the navigation becomes a full document load - the user lands on the new build without seeing an incompatible state. - Only real breakage (a /build asset 404 or failed chunk import) starts recovery: an overlay goes up immediately, the client polls /build-version and reloads once the server reports a different build. If versions never diverge or the reload budget is spent, the overlay offers a manual reload instead of a dead page. - Form fields and scroll position are snapshotted to sessionStorage before a recovery reload and restored after (native value setters + input events so React state stays in sync). history.state is deliberately not restored - the Remix router owns it.
1 parent 6d78b12 commit bc0e686

3 files changed

Lines changed: 181 additions & 41 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. 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.
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`, and all responses carry an `X-Build-Id` header. A working page is never touched: when a Remix navigation reveals a new build, it becomes a full document load. Only real breakage (a `/build` asset 404 or failed chunk import) triggers recovery: an overlay goes up, the client polls the new `/build-version` endpoint and reloads once the server reports a different build (form fields and scroll are snapshotted and restored across the reload); if versions never diverge or the reload budget is spent, the overlay offers a manual reload.

apps/webapp/app/components/StaleAssetRecovery.tsx

Lines changed: 171 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,210 @@
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.
1+
// Recovers from deploys rotating the content-hashed /build assets out from
2+
// under a page. Policy:
3+
//
4+
// 1. A working page is never touched just because a new build exists. Remix
5+
// loader fetches (?_data=) are stamped with X-Build-Id by the server;
6+
// when a navigation reveals a different build, the navigation is turned
7+
// into a full document load — the user lands on the new build without
8+
// ever seeing an incompatible state.
9+
// 2. Only real incompatibility (a /build stylesheet/script 404 or a failed
10+
// chunk import) triggers recovery: an overlay goes up immediately, the
11+
// script polls /build-version with backoff, and reloads once the server
12+
// reports a different build than the page was rendered with
13+
// (window.__remixManifest.version). If versions never diverge or the
14+
// reload budget (one per observed build, 2 total) is spent, the overlay
15+
// offers a manual reload instead of leaving a dead page.
16+
// 3. Before a recovery reload, form fields and scroll position are
17+
// snapshotted to sessionStorage and restored (best-effort) after the
18+
// reload. history.state is deliberately not restored — the Remix router
19+
// owns it, and reviving a stale one can desync the router.
20+
//
21+
// Must render before <Links /> so the listener precedes the stylesheet.
922
const script = `(function () {
10-
var KEY = "trigger:assetRecovery";
23+
var VKEY = "trigger:assetRecovery";
24+
var SKEY = "trigger:recoverySnapshot";
1125
var MAX_RELOADS = 2;
1226
var RESET_AFTER = 300000;
27+
var SNAPSHOT_TTL = 30000;
1328
var CHECK_DELAYS = [0, 2000, 4000, 8000, 15000, 30000];
1429
var recovering = false;
30+
var navigated = false;
1531
16-
function readState() {
32+
function ownVersion() {
33+
return window.__remixManifest && window.__remixManifest.version;
34+
}
35+
36+
function readJson(key) {
1737
try {
18-
return JSON.parse(sessionStorage.getItem(KEY) || "{}");
38+
return JSON.parse(sessionStorage.getItem(key) || "null");
1939
} catch (e) {
20-
return null;
40+
return undefined;
2141
}
2242
}
2343
24-
function writeState(state) {
44+
function writeJson(key, value) {
2545
try {
26-
sessionStorage.setItem(KEY, JSON.stringify(state));
46+
sessionStorage.setItem(key, JSON.stringify(value));
2747
return true;
2848
} catch (e) {
2949
return false;
3050
}
3151
}
3252
33-
function showBanner() {
53+
// ---- form + scroll snapshot ------------------------------------------
54+
55+
function takeSnapshot() {
56+
var fields = [];
57+
var els = document.querySelectorAll("input, textarea, select");
58+
for (var i = 0; i < els.length; i++) {
59+
var el = els[i];
60+
var type = (el.getAttribute("type") || "").toLowerCase();
61+
if (type === "password" || type === "file" || type === "hidden") continue;
62+
fields.push({
63+
i: i,
64+
id: el.id || null,
65+
name: el.name || null,
66+
tag: el.tagName,
67+
value: el.value,
68+
checked: el.checked === true,
69+
});
70+
}
71+
writeJson(SKEY, {
72+
t: Date.now(),
73+
path: location.pathname,
74+
scrollY: window.scrollY,
75+
fields: fields,
76+
});
77+
}
78+
79+
function setNativeValue(el, value) {
80+
// Go through the prototype setter so React's value tracking notices the
81+
// change when the input event fires.
82+
var proto =
83+
el.tagName === "TEXTAREA"
84+
? window.HTMLTextAreaElement
85+
: el.tagName === "SELECT"
86+
? window.HTMLSelectElement
87+
: window.HTMLInputElement;
88+
var descriptor = Object.getOwnPropertyDescriptor(proto.prototype, "value");
89+
if (descriptor && descriptor.set) {
90+
descriptor.set.call(el, value);
91+
} else {
92+
el.value = value;
93+
}
94+
}
95+
96+
function restoreSnapshot() {
97+
var snapshot = readJson(SKEY);
98+
try {
99+
sessionStorage.removeItem(SKEY);
100+
} catch (e) {}
101+
if (!snapshot || snapshot.path !== location.pathname) return;
102+
if (Date.now() - (snapshot.t || 0) > SNAPSHOT_TTL) return;
103+
var els = document.querySelectorAll("input, textarea, select");
104+
for (var i = 0; i < snapshot.fields.length; i++) {
105+
var field = snapshot.fields[i];
106+
var el = (field.id && document.getElementById(field.id)) || els[field.i];
107+
if (!el || el.tagName !== field.tag || (el.name || null) !== field.name) continue;
108+
if (el.type === "checkbox" || el.type === "radio") {
109+
// click() keeps React state in sync with the DOM
110+
if (el.checked !== field.checked) el.click();
111+
} else if (field.value && el.value !== field.value) {
112+
setNativeValue(el, field.value);
113+
el.dispatchEvent(new Event("input", { bubbles: true }));
114+
el.dispatchEvent(new Event("change", { bubbles: true }));
115+
}
116+
}
117+
if (snapshot.scrollY) window.scrollTo(0, snapshot.scrollY);
118+
}
119+
120+
window.addEventListener("load", function () {
121+
setTimeout(restoreSnapshot, 100);
122+
});
123+
124+
function doReload() {
125+
takeSnapshot();
126+
location.reload();
127+
}
128+
129+
// ---- scenario 1: working page, navigation as the update point --------
130+
131+
var origFetch = window.fetch;
132+
window.fetch = function (input) {
133+
var result = origFetch.apply(this, arguments);
134+
try {
135+
var url = typeof input === "string" ? input : input && input.url;
136+
if (url && url.indexOf("_data=") !== -1) {
137+
result.then(function (response) {
138+
var server = response.headers.get("X-Build-Id");
139+
var mine = ownVersion();
140+
if (server && mine && server !== mine && !navigated && !recovering) {
141+
navigated = true;
142+
var target = new URL(url, location.origin);
143+
target.searchParams.delete("_data");
144+
location.assign(target.toString());
145+
}
146+
}, function () {});
147+
}
148+
} catch (e) {}
149+
return result;
150+
};
151+
152+
// ---- scenario 2: page is actually broken ------------------------------
153+
154+
function showOverlay(final) {
34155
if (!document.body) {
35-
document.addEventListener("DOMContentLoaded", showBanner);
156+
document.addEventListener("DOMContentLoaded", function () {
157+
showOverlay(final);
158+
});
36159
return;
37160
}
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.";
161+
var text = final
162+
? "This page failed to load properly. Please reload."
163+
: "An update is being deployed. This page will reload automatically.";
164+
var existing = document.getElementById("stale-asset-overlay-text");
165+
if (existing) {
166+
existing.textContent = text;
167+
return;
168+
}
169+
var overlay = document.createElement("div");
170+
overlay.id = "stale-asset-overlay";
171+
overlay.style.cssText =
172+
"position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;background:#121317;color:#d7d9dd;font:15px/1.5 system-ui,sans-serif;text-align:center;padding:24px";
173+
var message = document.createElement("p");
174+
message.id = "stale-asset-overlay-text";
175+
message.textContent = text;
45176
var button = document.createElement("button");
46177
button.textContent = "Reload";
47178
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);
179+
"border:0;border-radius:4px;padding:7px 18px;background:#6366f1;color:#fff;font:inherit;cursor:pointer";
180+
button.onclick = doReload;
181+
overlay.appendChild(message);
182+
overlay.appendChild(button);
183+
document.body.appendChild(overlay);
55184
}
56185
57186
function reloadFor(serverVersion) {
58-
var state = readState();
59-
if (!state) return showBanner();
187+
var state = readJson(VKEY);
188+
if (state === undefined) return showOverlay(true);
189+
state = state || {};
60190
if (Date.now() - (state.t || 0) > RESET_AFTER) state = {};
61191
// One reload per observed server version, MAX_RELOADS total: a page that
62-
// is still broken after reloading for this build shows the banner
192+
// is still broken after reloading for this build gets the manual overlay
63193
// 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();
194+
if (state.v === serverVersion || (state.reloads || 0) >= MAX_RELOADS) return showOverlay(true);
195+
if (!writeJson(VKEY, { v: serverVersion, reloads: (state.reloads || 0) + 1, t: Date.now() })) {
196+
return showOverlay(true);
67197
}
68-
location.reload();
198+
doReload();
69199
}
70200
71201
function check(attempt) {
72-
fetch("/build-version", { cache: "no-store" })
202+
origFetch("/build-version", { cache: "no-store" })
73203
.then(function (response) {
74204
return response.json();
75205
})
76206
.then(function (data) {
77-
var mine = window.__remixManifest && window.__remixManifest.version;
207+
var mine = ownVersion();
78208
if (data && data.version && mine && data.version !== mine) {
79209
reloadFor(data.version);
80210
} else {
@@ -88,7 +218,7 @@ const script = `(function () {
88218
89219
function scheduleNext(attempt) {
90220
var next = attempt + 1;
91-
if (next >= CHECK_DELAYS.length) return showBanner();
221+
if (next >= CHECK_DELAYS.length) return showOverlay(true);
92222
setTimeout(function () {
93223
check(next);
94224
}, CHECK_DELAYS[next]);
@@ -97,6 +227,7 @@ const script = `(function () {
97227
function recover() {
98228
if (recovering) return;
99229
recovering = true;
230+
showOverlay(false);
100231
// __remixManifest is set by an inline script near the end of body; wait
101232
// for the document to finish parsing before comparing versions.
102233
if (document.readyState === "loading") {

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+
// Stamp every response with the build this replica is running. The
138+
// client-side stale-asset recovery reads it from Remix loader fetches to
139+
// turn an ordinary navigation into a full document load when the server
140+
// has moved to a new build.
141+
app.use((_req, res, next) => {
142+
res.set("X-Build-Id", build.assets.version);
143+
next();
144+
});
145+
137146
// Reports the build this replica is running. The client-side stale-asset
138147
// recovery polls it after a /build 404 and reloads only once the server
139148
// reports a different build than the one the page was rendered with.

0 commit comments

Comments
 (0)