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 <noreply@anthropic.com>
This commit is contained in:
rxwatcher
2026-07-22 13:43:57 +02:00
co-authored by Claude Fable 5
parent bcc78e7332
commit 8dc2dcbc13
3 changed files with 94 additions and 0 deletions
+56
View File
@@ -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<string, string>();
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);
});
});
+35
View File
@@ -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<PreloadErrorReloadDeps>): 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();
});
}
+3
View File
@@ -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(<App />);