Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/rules/server-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ area: webapp
type: fix
---

Brief description of what changed and why.
Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically.
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.
6 changes: 6 additions & 0 deletions .server-changes/stale-deploy-asset-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Fix pages occasionally loading unstyled or failing to load during a deploy. The dashboard now reloads automatically to recover.
56 changes: 56 additions & 0 deletions apps/webapp/app/components/StaleAssetRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { staleAssetRecoveryScript } from "./StaleAssetRecovery";

// Each staleAssetRecoveryScript() call models a fresh page load: it reads the shared
// sessionStorage budget and returns its own `recover`. We drive recover() directly rather
// than dispatching resource-error events, so accumulated window listeners never fire.
describe("staleAssetRecoveryScript", () => {
let reload: ReturnType<typeof vi.fn>;

beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
vi.stubGlobal("location", { reload });
vi.stubGlobal("navigator", { onLine: true });
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("reloads on a recovery", () => {
staleAssetRecoveryScript().recover();
expect(reload).toHaveBeenCalledTimes(1);
});

it("reloads only once per page even if several assets fail (re-entrancy guard)", () => {
const { recover } = staleAssetRecoveryScript();
recover();
recover();
recover();
expect(reload).toHaveBeenCalledTimes(1);
});

it("stops reloading once the budget is spent across reloads", () => {
staleAssetRecoveryScript().recover(); // reload 1
staleAssetRecoveryScript().recover(); // reload 2
staleAssetRecoveryScript().recover(); // budget spent -> no reload
expect(reload).toHaveBeenCalledTimes(2);
});

it("does not reload when offline", () => {
vi.stubGlobal("navigator", { onLine: false });
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});

it("does not reload when sessionStorage is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});
});
94 changes: 94 additions & 0 deletions apps/webapp/app/components/StaleAssetRecovery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Recovers from a rolling deploy rotating the content-hashed /build assets out from
// under a page. Each image serves only its own build and hard-404s unknown hashes, so
// a client can request a hash the serving replica doesn't have and get missing styles
// or a failed asset load. On such a /build load failure we do a bounded full document
// reload: the fresh document (and, under sticky routing, all of its assets) lands on a
// single live build, so the asset resolves. Bounded via sessionStorage so it can never
// loop; when the budget is spent it stops rather than reloading forever.
//
// Deliberately minimal — no fetch interception, no build-version polling, no server
// build-id contract, no form snapshot, no blocking overlay.

// The recovery logic runs as an inline <script> injected before <Links /> (see the
// component below), so it must execute before the app bundle and before the stylesheet
// can fail to load. It is authored as a normal, type-checked and lint-checked function
// and serialized with .toString() at render time — NOT hand-written into a string — so
// the logic is real code the compiler and linter can see. Because it is serialized, it
// must stay fully self-contained: no imports, no references to module scope, and plain
// ES that the bundler won't rewrite to reach a hoisted helper. It returns its `recover`
// closure purely so the unit test can drive the logic directly (the inline IIFE that
// runs in the browser ignores the return value).
export function staleAssetRecoveryScript() {
var KEY = "trigger:assetReload";
var MAX_RELOADS = 2;
var WINDOW_MS = 300000;
var recovering = false;

function budgetAllows() {
try {
var raw = sessionStorage.getItem(KEY);
var state = raw ? (JSON.parse(raw) as { n: number; t: number }) : { n: 0, t: 0 };
if (Date.now() - state.t > WINDOW_MS) state = { n: 0, t: 0 };
if (state.n >= MAX_RELOADS) return false;
sessionStorage.setItem(KEY, JSON.stringify({ n: state.n + 1, t: Date.now() }));
return true;
} catch {
// Storage blocked (private mode / quota): can't bound reloads, so don't auto-reload.
return false;
}
}

function recover() {
// One recovery per page: a broken load fails several /build assets at once and each
// fires its own error event before location.reload() commits — without this guard a
// single incident would burn the entire reload budget.
if (recovering) return;
recovering = true;
// Don't reload into the browser's offline error page.
if (navigator.onLine === false) return;
if (budgetAllows()) location.reload();
}

// Non-bubbling resource load failures (stylesheet, modulepreload, entry <script>) at
// document load — the failure class nothing else covers. Capture phase is required.
window.addEventListener(
"error",
function (event) {
var el = event.target as Element | null;
if (!el || typeof el.tagName !== "string") return; // window/global errors have no tagName
var url =
el.tagName === "LINK"
? (el as HTMLLinkElement).href
: el.tagName === "SCRIPT"
? (el as HTMLScriptElement).src
: null;
if (url && url.indexOf("/build/") !== -1) recover();
},
true
);

// Raw dynamic import() failures in app code. (Remix reloads its own route chunks, so
// that path rarely reaches here.) The message URL isn't reliable cross-browser, so
// match the chunk-load error shape; the once-guard + bounded budget make a rare stray
// reload harmless.
window.addEventListener("unhandledrejection", function (event) {
var message = (event.reason && event.reason.message) || "";
if (
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
) {
recover();
}
});

return { recover };
}

export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) {
if (!isProduction) {
return null;
}

return (
<script dangerouslySetInnerHTML={{ __html: `(${staleAssetRecoveryScript.toString()})()` }} />
);
}
6 changes: 6 additions & 0 deletions apps/webapp/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ 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'");
Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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";
Expand All @@ -18,6 +19,11 @@ 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 }];
};
Expand Down Expand Up @@ -99,6 +105,7 @@ export function ErrorBoundary() {
<head>
<meta charSet="utf-8" />

<StaleAssetRecovery isProduction={isProduction} />
<Meta />
<Links />
</head>
Expand All @@ -125,6 +132,7 @@ export default function App() {
<>
<html lang="en" className="h-full" data-theme="dark">
<head>
<StaleAssetRecovery isProduction={isProduction} />
<Meta />
<Links />
</head>
Expand Down
9 changes: 9 additions & 0 deletions apps/webapp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;

if (process.env.HTTP_SERVER_DISABLED !== "true") {
// Back-compat shim: a previously-deployed client build polls this endpoint after a
// /build asset 404 and reloads once it reports a newer build id, letting those older
// tabs recover in a single reload. Temporary — safe to remove once older clients have
// churned out. Deliberately does NOT set an X-Build-Id response header.
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;
Expand Down