From eb55530df90e7915f392b38a9b3e47d86e3cc25d Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:50:10 +0100 Subject: [PATCH 1/3] fix(webapp): recover from stale /build assets via a bounded reload On a /build asset or chunk load failure, do a bounded full document reload (max 2 per 5 min) so the page lands on a single consistent build after a rolling deploy. Replaces the reverted #4260 recovery with a minimal reload-only approach: no fetch interception, no build-version polling, no form snapshot, no blocking overlay. --- .../stale-deploy-asset-recovery.md | 6 ++ .../app/components/StaleAssetRecovery.test.ts | 56 +++++++++++ .../app/components/StaleAssetRecovery.tsx | 94 +++++++++++++++++++ apps/webapp/app/entry.server.tsx | 6 ++ apps/webapp/app/root.tsx | 8 ++ 5 files changed, 170 insertions(+) create mode 100644 .server-changes/stale-deploy-asset-recovery.md create mode 100644 apps/webapp/app/components/StaleAssetRecovery.test.ts create mode 100644 apps/webapp/app/components/StaleAssetRecovery.tsx diff --git a/.server-changes/stale-deploy-asset-recovery.md b/.server-changes/stale-deploy-asset-recovery.md new file mode 100644 index 00000000000..ae34351fbce --- /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 00000000000..0586189e1d6 --- /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 00000000000..7827d9ac802 --- /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