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