/** * Handling for `vite:preloadError` — a route's lazy chunk failing to load. * * Two very different causes produce the same `TypeError: Failed to fetch * dynamically imported module`: * * 1. The server serving this bundle went away (in the desktop app, the * sidecar exited under a live window). Every subsequent chunk fails too. * Nothing is broken in the app — it is the same event the main process * already surfaces as "server disconnected", so this must show the * reconnect surface and report nothing. * 2. The server is up but the chunk hash is stale, because a deploy replaced * the bundle under a long-lived tab. One reload fixes it. * * A health probe tells them apart. Without this handler the rejection is * unhandled, so the user gets a dead route and the crash channel gets a * TypeError for what is usually an ordinary shutdown. */ export const PRELOAD_ERROR_EVENT = "vite:preloadError"; /** How long to wait between health probes while the server is unreachable. */ const RECONNECT_POLL_MS = 1_000; export interface PreloadErrorEnvironment { readonly target: EventTarget; /** Resolves true when the server answers its health endpoint. */ readonly probeServer: () => Promise; /** Render the "lost connection, reconnecting" surface. */ readonly showDisconnected: () => void; readonly reload: () => void; /** Report a chunk failure that a reload could not resolve. */ readonly report: (error: unknown) => void; /** Has this session already spent its one automatic reload? */ readonly readReloadFlag: () => boolean; readonly writeReloadFlag: () => void; readonly delay: (ms: number) => Promise; } export type PreloadErrorOutcome = /** Server was down; waited for it and reloaded once it answered. */ | "reconnected" /** Server was up, so the chunk was stale; reloaded once. */ | "reloaded" /** A reload already happened this session and did not help; reported. */ | "reported"; export const respondToPreloadError = async ( env: PreloadErrorEnvironment, error: unknown, ): Promise => { if (await env.probeServer()) { // The origin answers, so this is a stale chunk rather than a dead server. // Exactly one reload per session, so a chunk that stays missing cannot // turn into a reload loop — it gets reported instead. if (env.readReloadFlag()) { env.report(error); return "reported"; } env.writeReloadFlag(); env.reload(); return "reloaded"; } env.showDisconnected(); for (;;) { await env.delay(RECONNECT_POLL_MS); if (await env.probeServer()) { env.reload(); return "reconnected"; } } }; /** Vite dispatches the failure as `CustomEvent<{ payload: Error }>`. */ const preloadErrorPayload = (event: Event): unknown => (event as CustomEvent<{ readonly payload?: unknown }>).detail?.payload; /** Registers the handler; returns a disposer. */ export const installPreloadErrorHandler = (env: PreloadErrorEnvironment): (() => void) => { const handler = (event: Event) => { // Always claim the event: Vite rethrows an unclaimed preload error into the // page as an unhandled rejection, which is the crash report we are here to // replace with a real recovery path. event.preventDefault(); void respondToPreloadError(env, preloadErrorPayload(event)); }; env.target.addEventListener(PRELOAD_ERROR_EVENT, handler); return () => env.target.removeEventListener(PRELOAD_ERROR_EVENT, handler); };