import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import StorageProvider from '../local-storage-shim'; import { isNullOrEmpty } from './is-null-or-empty'; import TemporalUtils from './temporal-utils'; const RELOAD_DEBOUNCE_MS = 3000; const LAST_RELOAD_KEY = 'errLastReload'; /** * `vite:preloadError` fires synchronously from Vite's preload helper, typically * before the router's navigation promise rejects into `onError`. The router * handler is the better healer because it knows the intended `to` path (so the * user lands where they were headed, not merely reloaded in place). So the preload * handler defers briefly, letting `router.onError` claim the debounce slot with a * target path first; the deferred reload then no-ops on the shared debounce. */ const PRELOAD_DEFER_MS = 250; /** Flag persisted on the shared window object so the preload listener binds exactly once. */ const PRELOAD_GUARD_FLAG = '__viteErrHandlerBound'; /** Minimal structural view of a vue-router instance — avoids coupling powerduck to vue-router. */ interface GuardableRouter { onError: (handler: (error: unknown, to?: { path?: string | null } | null) => void) => void; } /** * Self-heals the "stale deployment" chunk-load failure: after a deploy purges the * old fingerprinted assets, a still-open SPA that lazy-loads a route/chunk requests * a URL that no longer exists and the navigation blows up. A single forced full * reload pulls the fresh asset manifest and recovers. * * Under the previous webpack + Vue 2 build a failed lazy chunk surfaced as an Error * whose `name` is 'ChunkLoadError'. Vite never sets that name — a failed dynamic * import rejects with a plain TypeError whose message differs per browser (and a * MIME-type / SyntaxError variant when the SPA history-fallback serves index.html * for a purged asset URL). Matching only on the webpack name meant the self-heal * never fired on Vite; this recognises the Vite/browser signatures instead. * * Two independent failure surfaces are covered: * - `router.onError` (via {@link bindGuard}) — a failed lazy route/chunk import * during navigation; the router knows the intended `to` path. * - the `vite:preloadError` window event (via {@link bindPreloadGuard}, wired once * from PowerduckInitializer) — Vite's own signal that a module/CSS preload failed, * which fires even when the failure never reaches the router. * Both funnel through the same storage-backed debounce, so the two firing for one * underlying failure cannot double-reload or trap the user in a reload loop. */ export default class ChunkLoadRecovery { private static readonly MESSAGE_SIGNATURES = [ 'error loading dynamically imported module', // Firefox 'failed to fetch dynamically imported module', // Chromium / Edge 'failed to load module script', // MIME mismatch (index.html served for a purged chunk) 'importing a module script failed', // Safari 'unable to preload css', // Vite CSS preload helper ]; static isChunkLoadError(err: unknown): boolean { if (!(err instanceof Error)) { return false; } if (err.name == 'ChunkLoadError') { return true; // legacy webpack name — kept so nothing regresses } const message = err.message.toLowerCase(); return ChunkLoadRecovery.MESSAGE_SIGNATURES.some(signature => message.includes(signature)); } /** * Wires the router self-heal onto the given router: `router.onError` reloads to the * intended `to` path on a failed lazy navigation. Also ensures the `vite:preloadError` * guard is bound (idempotent) so a router-less caller still gets preload recovery. */ static bindGuard(router: GuardableRouter): void { router.onError((err, to) => { if (ChunkLoadRecovery.isChunkLoadError(err)) { ChunkLoadRecovery.triggerReload(to?.path); } }); ChunkLoadRecovery.bindPreloadGuard(); } /** * Binds the `vite:preloadError` window listener exactly once — the guard flag lives on * the shared `globalState` (window) object, so it holds even across separate module * instances. Called from PowerduckInitializer at framework init (before any router * exists); safe to call again from {@link bindGuard}. */ static bindPreloadGuard(): void { if (typeof window === 'undefined' || globalState[PRELOAD_GUARD_FLAG] == true) { return; } globalState[PRELOAD_GUARD_FLAG] = true; // `vite:preloadError` is by definition a chunk/CSS preload failure, so it always // warrants the self-heal. Defer the reload by PRELOAD_DEFER_MS so `router.onError` // — which knows the intended `to` path — can claim the debounce slot first and // navigate there; otherwise fall back to reloading the current location in place. globalState.addEventListener('vite:preloadError', () => { globalState.setTimeout(() => ChunkLoadRecovery.triggerReload(), PRELOAD_DEFER_MS); }); } /** * Debounced to once per RELOAD_DEBOUNCE_MS so a chunk that is genuinely gone * (not merely stale) can't trap the user in an infinite reload loop. The debounce * slot is claimed in storage *before* navigating, so a second trigger (e.g. the * `vite:preloadError` event firing for the same failure the router already caught) * reads the just-written timestamp and bails — no double reload, no race. */ private static triggerReload(targetPath?: string | null): void { if (typeof window === 'undefined' || !ChunkLoadRecovery.claimReloadSlot()) { return; } if (!isNullOrEmpty(targetPath)) { globalState.location.href = targetPath; } else { globalState.location.reload(); } } /** * Atomically (single-threaded read-check-write) claims the next reload slot. The * storage key is app-prefixed so co-hosted powerduck apps don't share a slot. * Returns false when a reload fired within the debounce window. */ private static claimReloadSlot(): boolean { const key = `${PowerduckState.getAppPrefix()}${LAST_RELOAD_KEY}`; const now = TemporalUtils.dateNowMs(); const lastTry = Number(StorageProvider.getString(key) || '0'); if (now - lastTry <= RELOAD_DEBOUNCE_MS) { return false; } StorageProvider.setString(key, now.toString()); return true; } }