Standard `git revert` of #4260. Its client-side stale-asset recovery is net-negative during normal deploys: - The `fetch` interception treats any `?_data=` request (Remix loader **and** action traffic) as a navigation and, on a build-id mismatch, `location.assign`es the tab to the fetched URL — an open dashboard tab can be hard-navigated to a raw data URL during a rolling deploy, losing unsaved input. - Any transient `/build` asset error (a network blip, an extension, an unrelated failed dynamic import) blanks the page behind a full-screen overlay for ~60s before offering a manual reload. - It serialized form field values to `sessionStorage` to restore them across the reload. This returns the webapp to the pre-#4260 baseline as a fast, low-risk step. Follow-ups (separate PRs): - a minimal reload-only recovery to replace this, - restore the unrelated `.claude/rules/server-apps.md` docs tidy-up from #4260 (via cherry-pick), - a load-balancer stickiness change addressing the root cause.
This commit is contained in:
@@ -14,12 +14,10 @@ area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically.
|
||||
Brief description of what changed and why.
|
||||
EOF
|
||||
```
|
||||
|
||||
- **area**: `webapp` | `supervisor`
|
||||
- **type**: `feature` | `fix` | `improvement` | `breaking`
|
||||
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix pages occasionally loading unstyled or failing to load during deploys. The dashboard now detects this and reloads to recover automatically, or prompts you to reload if it can't.
|
||||
@@ -1,272 +0,0 @@
|
||||
// Recovers from deploys rotating the content-hashed /build assets out from
|
||||
// under a page. Policy:
|
||||
//
|
||||
// 1. A working page is never touched just because a new build exists. Remix
|
||||
// loader fetches (?_data=) are stamped with X-Build-Id by the server;
|
||||
// when a navigation reveals a different build, the navigation is turned
|
||||
// into a full document load — the user lands on the new build without
|
||||
// ever seeing an incompatible state.
|
||||
// 2. Only real incompatibility (a /build stylesheet/script 404 or a failed
|
||||
// chunk import) triggers recovery: an overlay goes up immediately, the
|
||||
// script polls /build-version with backoff, and reloads once the server
|
||||
// reports a different build than the page was rendered with
|
||||
// (window.__remixManifest.version). If versions never diverge or the
|
||||
// reload budget (one per observed build, 2 total) is spent, the overlay
|
||||
// offers a manual reload instead of leaving a dead page.
|
||||
// 3. Before a recovery reload, form fields and scroll position are
|
||||
// snapshotted to sessionStorage and restored (best-effort) after the
|
||||
// reload. history.state is deliberately not restored — the Remix router
|
||||
// owns it, and reviving a stale one can desync the router.
|
||||
//
|
||||
// Must render before <Links /> so the listener precedes the stylesheet.
|
||||
const script = `(function () {
|
||||
var VKEY = "trigger:assetRecovery";
|
||||
var SKEY = "trigger:recoverySnapshot";
|
||||
var MAX_RELOADS = 2;
|
||||
var RESET_AFTER = 300000;
|
||||
var SNAPSHOT_TTL = 30000;
|
||||
var CHECK_DELAYS = [0, 2000, 4000, 8000, 15000, 30000];
|
||||
var recovering = false;
|
||||
var navigated = false;
|
||||
|
||||
function ownVersion() {
|
||||
return window.__remixManifest && window.__remixManifest.version;
|
||||
}
|
||||
|
||||
function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(key) || "null");
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- form + scroll snapshot ------------------------------------------
|
||||
|
||||
function takeSnapshot() {
|
||||
var fields = [];
|
||||
var els = document.querySelectorAll("input, textarea, select");
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
var type = (el.getAttribute("type") || "").toLowerCase();
|
||||
if (type === "password" || type === "file" || type === "hidden") continue;
|
||||
fields.push({
|
||||
i: i,
|
||||
id: el.id || null,
|
||||
name: el.name || null,
|
||||
tag: el.tagName,
|
||||
value: el.value,
|
||||
checked: el.checked === true,
|
||||
});
|
||||
}
|
||||
writeJson(SKEY, {
|
||||
t: Date.now(),
|
||||
path: location.pathname,
|
||||
scrollY: window.scrollY,
|
||||
fields: fields,
|
||||
});
|
||||
}
|
||||
|
||||
function setNativeValue(el, value) {
|
||||
// Go through the prototype setter so React's value tracking notices the
|
||||
// change when the input event fires.
|
||||
var proto =
|
||||
el.tagName === "TEXTAREA"
|
||||
? window.HTMLTextAreaElement
|
||||
: el.tagName === "SELECT"
|
||||
? window.HTMLSelectElement
|
||||
: window.HTMLInputElement;
|
||||
var descriptor = Object.getOwnPropertyDescriptor(proto.prototype, "value");
|
||||
if (descriptor && descriptor.set) {
|
||||
descriptor.set.call(el, value);
|
||||
} else {
|
||||
el.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSnapshot() {
|
||||
var snapshot = readJson(SKEY);
|
||||
try {
|
||||
sessionStorage.removeItem(SKEY);
|
||||
} catch (e) {}
|
||||
if (!snapshot || snapshot.path !== location.pathname) return;
|
||||
if (Date.now() - (snapshot.t || 0) > SNAPSHOT_TTL) return;
|
||||
var els = document.querySelectorAll("input, textarea, select");
|
||||
for (var i = 0; i < snapshot.fields.length; i++) {
|
||||
var field = snapshot.fields[i];
|
||||
var el = (field.id && document.getElementById(field.id)) || els[field.i];
|
||||
if (!el || el.tagName !== field.tag || (el.name || null) !== field.name) continue;
|
||||
if (el.type === "checkbox" || el.type === "radio") {
|
||||
// click() keeps React state in sync with the DOM
|
||||
if (el.checked !== field.checked) el.click();
|
||||
} else if (field.value != null && el.value !== field.value) {
|
||||
setNativeValue(el, field.value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
if (snapshot.scrollY) window.scrollTo(0, snapshot.scrollY);
|
||||
}
|
||||
|
||||
window.addEventListener("load", function () {
|
||||
setTimeout(restoreSnapshot, 100);
|
||||
});
|
||||
|
||||
function doReload() {
|
||||
takeSnapshot();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ---- scenario 1: working page, navigation as the update point --------
|
||||
|
||||
var origFetch = window.fetch;
|
||||
window.fetch = function (input) {
|
||||
var result = origFetch.apply(this, arguments);
|
||||
try {
|
||||
var url = typeof input === "string" ? input : input && input.url;
|
||||
if (url && url.indexOf("_data=") !== -1) {
|
||||
result.then(function (response) {
|
||||
var server = response.headers.get("X-Build-Id");
|
||||
var mine = ownVersion();
|
||||
if (server && mine && server !== mine && !navigated && !recovering) {
|
||||
navigated = true;
|
||||
var target = new URL(url, location.origin);
|
||||
target.searchParams.delete("_data");
|
||||
location.assign(target.toString());
|
||||
}
|
||||
}, function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---- scenario 2: page is actually broken ------------------------------
|
||||
|
||||
function showOverlay(final) {
|
||||
if (!document.body) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
showOverlay(final);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ASCII only: documents are served without an explicit charset, so
|
||||
// non-ASCII here can render as mojibake on a broken page.
|
||||
var text = final ? "This page failed to load properly. Please reload." : "Loading...";
|
||||
var existing = document.getElementById("stale-asset-overlay-text");
|
||||
if (existing) {
|
||||
existing.textContent = text;
|
||||
if (final) document.getElementById("stale-asset-overlay-button").style.display = "";
|
||||
return;
|
||||
}
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "stale-asset-overlay";
|
||||
overlay.style.cssText =
|
||||
"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";
|
||||
var message = document.createElement("p");
|
||||
message.id = "stale-asset-overlay-text";
|
||||
message.textContent = text;
|
||||
var button = document.createElement("button");
|
||||
button.id = "stale-asset-overlay-button";
|
||||
button.textContent = "Reload";
|
||||
button.style.cssText =
|
||||
"border:0;border-radius:4px;padding:7px 18px;background:#6366f1;color:#fff;font:inherit;cursor:pointer" +
|
||||
(final ? "" : ";display:none");
|
||||
button.onclick = doReload;
|
||||
overlay.appendChild(message);
|
||||
overlay.appendChild(button);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function reloadFor(serverVersion) {
|
||||
var state = readJson(VKEY);
|
||||
if (state === undefined) return showOverlay(true);
|
||||
state = state || {};
|
||||
if (Date.now() - (state.t || 0) > RESET_AFTER) state = {};
|
||||
// One reload per observed server version, MAX_RELOADS total: a page that
|
||||
// is still broken after reloading for this build gets the manual overlay
|
||||
// instead of reloading again.
|
||||
if (state.v === serverVersion || (state.reloads || 0) >= MAX_RELOADS) return showOverlay(true);
|
||||
if (!writeJson(VKEY, { v: serverVersion, reloads: (state.reloads || 0) + 1, t: Date.now() })) {
|
||||
return showOverlay(true);
|
||||
}
|
||||
doReload();
|
||||
}
|
||||
|
||||
function check(attempt) {
|
||||
origFetch("/build-version", { cache: "no-store" })
|
||||
.then(function (response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var mine = ownVersion();
|
||||
if (data && data.version && mine && data.version !== mine) {
|
||||
reloadFor(data.version);
|
||||
} else {
|
||||
scheduleNext(attempt);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
scheduleNext(attempt);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleNext(attempt) {
|
||||
var next = attempt + 1;
|
||||
if (next >= CHECK_DELAYS.length) return showOverlay(true);
|
||||
setTimeout(function () {
|
||||
check(next);
|
||||
}, CHECK_DELAYS[next]);
|
||||
}
|
||||
|
||||
function recover() {
|
||||
if (recovering) return;
|
||||
recovering = true;
|
||||
showOverlay(false);
|
||||
// __remixManifest is set by an inline script near the end of body; wait
|
||||
// for the document to finish parsing before comparing versions.
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
check(0);
|
||||
});
|
||||
} else {
|
||||
check(0);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
"error",
|
||||
function (event) {
|
||||
var el = event.target;
|
||||
if (!el || el === window) return;
|
||||
var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null;
|
||||
if (url && url.indexOf("/build/") !== -1) recover();
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener("unhandledrejection", function (event) {
|
||||
var message = event.reason && event.reason.message;
|
||||
if (
|
||||
typeof message === "string" &&
|
||||
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
|
||||
) {
|
||||
recover();
|
||||
}
|
||||
});
|
||||
})();`;
|
||||
|
||||
export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) {
|
||||
if (!isProduction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -54,12 +54,6 @@ export default function handleRequest(
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Stale documents reference /build asset hashes that 404 after a deploy —
|
||||
// always revalidate HTML. Route-set headers win.
|
||||
if (!responseHeaders.has("Cache-Control")) {
|
||||
responseHeaders.set("Cache-Control", "no-cache");
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/login")) {
|
||||
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
|
||||
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ToastMessage } from "~/models/message.server";
|
||||
import { commitSession, getSession } from "~/models/message.server";
|
||||
import tailwindStylesheetUrl from "~/tailwind.css";
|
||||
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
||||
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
|
||||
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
|
||||
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
|
||||
import { Toast } from "./components/primitives/Toast";
|
||||
@@ -19,11 +18,6 @@ import { getUser } from "./services/session.server";
|
||||
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
|
||||
import { appEnvTitleTag } from "./utils";
|
||||
|
||||
// Derived here (not inside StaleAssetRecovery) so the shared component takes
|
||||
// the flag as a prop. NODE_ENV is statically replaced in browser bundles, and
|
||||
// the ErrorBoundary can't rely on loader data.
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
|
||||
};
|
||||
@@ -105,7 +99,6 @@ export function ErrorBoundary() {
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
|
||||
<StaleAssetRecovery isProduction={isProduction} />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
@@ -132,7 +125,6 @@ export default function App() {
|
||||
<>
|
||||
<html lang="en" className="h-full" data-theme="dark">
|
||||
<head>
|
||||
<StaleAssetRecovery isProduction={isProduction} />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
|
||||
@@ -134,23 +134,6 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
|
||||
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
|
||||
|
||||
if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
// Stamp every response with the build this replica is running. The
|
||||
// client-side stale-asset recovery reads it from Remix loader fetches to
|
||||
// turn an ordinary navigation into a full document load when the server
|
||||
// has moved to a new build.
|
||||
app.use((_req, res, next) => {
|
||||
res.set("X-Build-Id", build.assets.version);
|
||||
next();
|
||||
});
|
||||
|
||||
// Reports the build this replica is running. The client-side stale-asset
|
||||
// recovery polls it after a /build 404 and reloads only once the server
|
||||
// reports a different build than the one the page was rendered with.
|
||||
app.get("/build-version", (_req, res) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({ version: build.assets.version });
|
||||
});
|
||||
|
||||
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
|
||||
const wss: WebSocketServer | undefined = build.entry.module.wss;
|
||||
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
|
||||
|
||||
Reference in New Issue
Block a user