diff --git a/.claude/rules/server-apps.md b/.claude/rules/server-apps.md index 9229a0b2f3..3bc4c0b2b9 100644 --- a/.claude/rules/server-apps.md +++ b/.claude/rules/server-apps.md @@ -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. diff --git a/.server-changes/stale-deploy-asset-recovery.md b/.server-changes/stale-deploy-asset-recovery.md new file mode 100644 index 0000000000..ae34351fbc --- /dev/null +++ b/.server-changes/stale-deploy-asset-recovery.md @@ -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. diff --git a/apps/webapp/app/components/StaleAssetRecovery.test.ts b/apps/webapp/app/components/StaleAssetRecovery.test.ts new file mode 100644 index 0000000000..0586189e1d --- /dev/null +++ b/apps/webapp/app/components/StaleAssetRecovery.test.ts @@ -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; + + 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(); + }); +}); diff --git a/apps/webapp/app/components/StaleAssetRecovery.tsx b/apps/webapp/app/components/StaleAssetRecovery.tsx new file mode 100644 index 0000000000..7827d9ac80 --- /dev/null +++ b/apps/webapp/app/components/StaleAssetRecovery.tsx @@ -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