Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/vitePreloadRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const reload = vi.fn();
const listeners = new Map<string, EventListener>();
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);
});
});
74 changes: 74 additions & 0 deletions apps/web/src/vitePreloadRecovery.ts
Original file line number Diff line number Diff line change
@@ -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<Storage, "getItem" | "setItem">;

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);
};
}
Loading