From 69050d7a39e7f33625a34b7cadc83c4850d952bb Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:34:27 +0000 Subject: [PATCH] fix(web): reload on stale lazy-chunk load after deploy Open tabs keep hashed dynamic-import URLs from the previous build; after a deploy those chunks disappear and DiffPanel/FilePreview crash with "Failed to fetch dynamically imported module". Listen for Vite's vite:preloadError and reload once (with cooldown) so the new asset map loads. Co-authored-by: Enrico Polanski <16064771+enricopolanski@users.noreply.github.com> --- apps/web/src/main.tsx | 5 ++ apps/web/src/vitePreloadRecovery.test.ts | 73 +++++++++++++++++++++++ apps/web/src/vitePreloadRecovery.ts | 74 ++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 apps/web/src/vitePreloadRecovery.test.ts create mode 100644 apps/web/src/vitePreloadRecovery.ts diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f654..0be3eda7452 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -20,6 +20,11 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; +import { installVitePreloadRecovery } from "./vitePreloadRecovery"; + +// After a deploy, open tabs still reference old hashed lazy chunks. Reload so +// the browser picks up the new index + asset map instead of hard-crashing. +installVitePreloadRecovery(); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/vitePreloadRecovery.test.ts b/apps/web/src/vitePreloadRecovery.test.ts new file mode 100644 index 00000000000..16d024fd3b6 --- /dev/null +++ b/apps/web/src/vitePreloadRecovery.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + installVitePreloadRecovery, + shouldReloadForPreloadError, + VITE_PRELOAD_RELOAD_COOLDOWN_MS, + VITE_PRELOAD_RELOAD_STORAGE_KEY, +} from "./vitePreloadRecovery"; + +describe("shouldReloadForPreloadError", () => { + it("allows the first reload", () => { + expect(shouldReloadForPreloadError(1_000, null)).toBe(true); + }); + + it("blocks reloads inside the cooldown window", () => { + const last = 10_000; + expect(shouldReloadForPreloadError(last + VITE_PRELOAD_RELOAD_COOLDOWN_MS - 1, last)).toBe( + false, + ); + }); + + it("allows another reload after the cooldown", () => { + const last = 10_000; + expect(shouldReloadForPreloadError(last + VITE_PRELOAD_RELOAD_COOLDOWN_MS, last)).toBe(true); + }); +}); + +describe("installVitePreloadRecovery", () => { + it("reloads once and records the attempt", () => { + const storage = new Map(); + const reload = vi.fn(); + const listeners = new Map(); + let now = 50_000; + + const dispose = installVitePreloadRecovery({ + storage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => { + storage.set(key, value); + }, + }, + reload, + now: () => now, + addEventListener: ((type: string, listener: EventListener) => { + listeners.set(type, listener); + }) as typeof window.addEventListener, + removeEventListener: ((type: string) => { + listeners.delete(type); + }) as typeof window.removeEventListener, + }); + + const event = { + preventDefault: vi.fn(), + } as unknown as Event; + + listeners.get("vite:preloadError")?.(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); + expect(storage.get(VITE_PRELOAD_RELOAD_STORAGE_KEY)).toBe(String(now)); + + // Same moment: cooldown blocks a second reload. + listeners.get("vite:preloadError")?.(event); + expect(reload).toHaveBeenCalledOnce(); + + now += VITE_PRELOAD_RELOAD_COOLDOWN_MS; + listeners.get("vite:preloadError")?.(event); + expect(reload).toHaveBeenCalledTimes(2); + + dispose(); + expect(listeners.has("vite:preloadError")).toBe(false); + }); +}); diff --git a/apps/web/src/vitePreloadRecovery.ts b/apps/web/src/vitePreloadRecovery.ts new file mode 100644 index 00000000000..9d4eee84875 --- /dev/null +++ b/apps/web/src/vitePreloadRecovery.ts @@ -0,0 +1,74 @@ +/** + * Recover from Vite version skew: after a deploy, an open tab still points at + * old hashed lazy chunks. Dynamic import then fails with + * "Failed to fetch dynamically imported module". Vite emits `vite:preloadError` + * for this; reloading picks up the new index + chunk map. + * + * Cooldown avoids a tight reload loop when the failure is not skew (offline, + * auth wall, etc.). + */ + +export const VITE_PRELOAD_RELOAD_STORAGE_KEY = "t3:vite-preload-reload-at"; +export const VITE_PRELOAD_RELOAD_COOLDOWN_MS = 10_000; + +export type VitePreloadRecoveryStorage = Pick; + +export function shouldReloadForPreloadError( + nowMs: number, + lastReloadAtMs: number | null, + cooldownMs: number = VITE_PRELOAD_RELOAD_COOLDOWN_MS, +): boolean { + if (lastReloadAtMs == null || !Number.isFinite(lastReloadAtMs)) { + return true; + } + return nowMs - lastReloadAtMs >= cooldownMs; +} + +function readLastReloadAt(storage: VitePreloadRecoveryStorage): number | null { + const raw = storage.getItem(VITE_PRELOAD_RELOAD_STORAGE_KEY); + if (raw == null || raw.length === 0) { + return null; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +export function installVitePreloadRecovery(options?: { + storage?: VitePreloadRecoveryStorage; + reload?: () => void; + now?: () => number; + addEventListener?: typeof window.addEventListener; + removeEventListener?: typeof window.removeEventListener; + cooldownMs?: number; +}): () => void { + if (typeof window === "undefined" && !options?.addEventListener) { + return () => undefined; + } + + const storage = options?.storage ?? window.sessionStorage; + const reload = options?.reload ?? (() => window.location.reload()); + const now = options?.now ?? Date.now; + const addEventListener = options?.addEventListener ?? window.addEventListener.bind(window); + const removeEventListener = + options?.removeEventListener ?? window.removeEventListener.bind(window); + const cooldownMs = options?.cooldownMs ?? VITE_PRELOAD_RELOAD_COOLDOWN_MS; + + const onPreloadError = (event: Event) => { + if (!shouldReloadForPreloadError(now(), readLastReloadAt(storage), cooldownMs)) { + return; + } + + // Prevent the default unhandled-rejection path so we own recovery. + if ("preventDefault" in event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + + storage.setItem(VITE_PRELOAD_RELOAD_STORAGE_KEY, String(now())); + reload(); + }; + + addEventListener("vite:preloadError", onPreloadError); + return () => { + removeEventListener("vite:preloadError", onPreloadError); + }; +}