From 8dc2dcbc13581b0322565a806ea4ff8df9d89813 Mon Sep 17 00:00:00 2001 From: rxwatcher Date: Mon, 20 Jul 2026 13:37:20 +0200 Subject: [PATCH] fix(web): reload once when a deployed chunk fails to import After a deploy, an open tab still references the previous build's content-hashed chunks and the first lazy navigation dies with "Failed to fetch dynamically imported module". Handle Vite's vite:preloadError by reloading onto the current build, guarded to at most one reload per minute so a persistently missing chunk cannot reload-loop. Co-Authored-By: Claude Fable 5 --- web/src/lib/reloadOnPreloadError.test.ts | 56 ++++++++++++++++++++++++ web/src/lib/reloadOnPreloadError.ts | 35 +++++++++++++++ web/src/main.tsx | 3 ++ 3 files changed, 94 insertions(+) create mode 100644 web/src/lib/reloadOnPreloadError.test.ts create mode 100644 web/src/lib/reloadOnPreloadError.ts diff --git a/web/src/lib/reloadOnPreloadError.test.ts b/web/src/lib/reloadOnPreloadError.test.ts new file mode 100644 index 00000000..f17990e8 --- /dev/null +++ b/web/src/lib/reloadOnPreloadError.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { installPreloadErrorReload } from "./reloadOnPreloadError"; + +function harness(now: () => number) { + const reload = vi.fn(); + const storage = new Map(); + return { + reload, + deps: { + reload, + now, + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => storage.set(k, v), + }, + }; +} + +describe("installPreloadErrorReload", () => { + it("reloads when a dynamically imported chunk fails to load", () => { + const { reload, deps } = harness(() => 1_000_000); + installPreloadErrorReload(deps); + + window.dispatchEvent(new Event("vite:preloadError")); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload again within the loop-guard window", () => { + let clock = 1_000_000; + const { reload, deps } = harness(() => clock); + + installPreloadErrorReload(deps); + window.dispatchEvent(new Event("vite:preloadError")); + expect(reload).toHaveBeenCalledTimes(1); + + // Post-reload page still hits the error seconds later: give up instead + // of reload-looping. + clock += 5_000; + installPreloadErrorReload(deps); + window.dispatchEvent(new Event("vite:preloadError")); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("reloads again once the guard window has passed (a later deploy)", () => { + let clock = 1_000_000; + const { reload, deps } = harness(() => clock); + + installPreloadErrorReload(deps); + window.dispatchEvent(new Event("vite:preloadError")); + expect(reload).toHaveBeenCalledTimes(1); + + clock += 10 * 60_000; + installPreloadErrorReload(deps); + window.dispatchEvent(new Event("vite:preloadError")); + expect(reload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/lib/reloadOnPreloadError.ts b/web/src/lib/reloadOnPreloadError.ts new file mode 100644 index 00000000..288fd42c --- /dev/null +++ b/web/src/lib/reloadOnPreloadError.ts @@ -0,0 +1,35 @@ +// After a deploy, an open tab's code still references the previous build's +// content-hashed chunks; the first lazy navigation then fails with +// "Failed to fetch dynamically imported module". Vite reports that as a +// window "vite:preloadError" event — reload once so the tab picks up the +// current build, with a time guard so a persistently broken chunk cannot +// cause a reload loop. + +const GUARD_KEY = "silo:preload-error-reload-at"; +const GUARD_WINDOW_MS = 60_000; + +interface PreloadErrorReloadDeps { + reload: () => void; + now: () => number; + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +export function installPreloadErrorReload(deps?: Partial): void { + const reload = deps?.reload ?? (() => window.location.reload()); + const now = deps?.now ?? Date.now; + const getItem = deps?.getItem ?? ((k: string) => sessionStorage.getItem(k)); + const setItem = deps?.setItem ?? ((k: string, v: string) => sessionStorage.setItem(k, v)); + + window.addEventListener("vite:preloadError", (event) => { + const lastReloadAt = Number(getItem(GUARD_KEY) ?? 0); + if (now() - lastReloadAt < GUARD_WINDOW_MS) { + // We already reloaded moments ago and the chunk is still missing; + // let the failure surface instead of reload-looping. + return; + } + event.preventDefault(); + setItem(GUARD_KEY, String(now())); + reload(); + }); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 55f8dcd6..52dcd3d7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,10 @@ import { createRoot } from "react-dom/client"; import App from "./App"; +import { installPreloadErrorReload } from "./lib/reloadOnPreloadError"; import "./app.css"; +installPreloadErrorReload(); + const root = document.getElementById("root"); if (root === null) throw new Error("Root element #root not found"); createRoot(root).render();