stale-deploy-reload.ts3.1 KBView on GitHub /**
* Recovery for "Failed to fetch dynamically imported module".
*
* A tab opened before a deploy 404s the moment it lazy-loads a chunk it had not already
* fetched. Nothing is broken — the running code just points at filenames the live build no
* longer serves — so one reload picks up the current build and the user carries on where they
* were.
*
* `publish-frontend-assets.sh` deliberately syncs WITHOUT `--delete`, which closes the common
* case: the previous build's chunks outlive the deploy, so a tab mid-session keeps loading.
* This covers what retention cannot — a chunk aged out of the bucket, a CloudFront miss on an
* object that never propagated, a tab left open across several deploys.
*
* Guarded by sessionStorage: a chunk that is genuinely missing from the live build must not
* put the tab into a reload loop.
*/
const RELOAD_MARKER_KEY=[redacted];
const RELOAD_COOLDOWN_MS = 60_000;
const CHUNK_LOAD_FAILURE_PATTERNS = [
// Chrome
'Failed to fetch dynamically imported module',
// Firefox
'error loading dynamically imported module',
// Safari
'Importing a module script failed',
];
function isChunkLoadFailure(reason: unknown): boolean {
const message =
reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : '';
return CHUNK_LOAD_FAILURE_PATTERNS.some((pattern) => message.includes(pattern));
}
function reloadedRecently(): boolean {
try {
const previous = Number(window.sessionStorage.getItem(RELOAD_MARKER_KEY));
return Number.isFinite(previous) && Date.now() - previous < RELOAD_COOLDOWN_MS;
} catch {
// Storage blocked (private mode, third-party cookie rules). Better to skip the reload
// than to risk a loop we cannot detect.
return true;
}
}
function markReloaded(): void {
try {
window.sessionStorage.setItem(RELOAD_MARKER_KEY, String(Date.now()));
} catch {
// Nothing to do — reloadedRecently() already refuses to reload without storage.
}
}
function recoverFromStaleDeploy(reason: unknown): void {
if (!isChunkLoadFailure(reason)) return;
// Offline reloads land on the browser's error page, which is strictly worse than the
// error boundary the user is already looking at.
if (navigator.onLine === false) return;
if (reloadedRecently()) return;
markReloaded();
window.location.reload();
}
export function installStaleDeployReload(): void {
// Vite fires this from its __vitePreload helper before the dynamic import rejects, with
// the underlying error hung off the event as `payload`. Left un-prevented so the failure
// still reaches Sentry.
window.addEventListener('vite:preloadError', (event) => {
recoverFromStaleDeploy((event as Event & { payload?: unknown }).payload);
});
// React.lazy rethrows its rejection during render rather than leaving it unhandled, so
// these two cover the imports that fail outside a lazy boundary.
window.addEventListener('unhandledrejection', (event) => {
recoverFromStaleDeploy(event.reason);
});
window.addEventListener('error', (event) => {
recoverFromStaleDeploy(event.error ?? event.message);
});
}