107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
|
|
|
|
/**
|
|
* `lazy()` with recovery from chunk-load failures.
|
|
*
|
|
* A lazy route's `import()` can fail transiently:
|
|
* • after a deploy, an already-open page still references chunk URLs that no
|
|
* longer exist on the server;
|
|
* • in dev, Vite's dependency optimizer can answer 504 ("Outdated Optimize
|
|
* Dep") for an optimized dependency after it re-optimized.
|
|
*
|
|
* Either way the import rejects and, with a plain `lazy()`, the route error
|
|
* boundary shows a dead "This page slammed" screen the user can do nothing with.
|
|
*
|
|
* Recovery strategy (bounded, loop-safe):
|
|
* 1. Retry the import a few times with a short backoff — this alone clears the
|
|
* optimizer's brief 504 window without any reload.
|
|
* 2. If it still fails, do a full reload (fetches a fresh index + module graph).
|
|
* 3. Reloads are throttled by a **cooldown** (not a once-per-session flag), so
|
|
* repeated stale-hash failures across a session each self-heal, while a
|
|
* genuine persistent failure can't spin the browser in a reload loop — it
|
|
* falls through to the error boundary as the last resort.
|
|
*
|
|
* ⚠️ The old version reloaded at most ONCE per session; the second failure then
|
|
* dead-ended on "This page slammed" until a manual reload. That is exactly the
|
|
* bug this rewrite fixes — don't reintroduce a once-per-session cap.
|
|
*/
|
|
const RELOAD_TS_KEY = 'skatespots:chunk-reload-at';
|
|
const RELOAD_COOLDOWN_MS = 10_000;
|
|
const IMPORT_ATTEMPTS = 3;
|
|
|
|
/**
|
|
* Pages take no props, so any props-compatible component type works here. Using
|
|
* `ComponentType<object>` (rather than `any`) keeps this type-safe while still
|
|
* accepting every page component in the router.
|
|
*/
|
|
type AnyComponent = ComponentType<object>;
|
|
|
|
function isChunkLoadError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Outdated Optimize Dep|Failed to load module script/i.test(
|
|
message,
|
|
);
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/** True when the last reload was long enough ago (or there was none) to reload again. */
|
|
function canReloadNow(): boolean {
|
|
try {
|
|
const last = Number(sessionStorage.getItem(RELOAD_TS_KEY) || 0);
|
|
return !Number.isFinite(last) || Date.now() - last > RELOAD_COOLDOWN_MS;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function markReload(): void {
|
|
try {
|
|
sessionStorage.setItem(RELOAD_TS_KEY, String(Date.now()));
|
|
} catch {
|
|
/* sessionStorage unavailable — the caller's reload still runs */
|
|
}
|
|
}
|
|
|
|
function clearReloadMark(): void {
|
|
try {
|
|
sessionStorage.removeItem(RELOAD_TS_KEY);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function lazyWithRetry(
|
|
factory: () => Promise<{ default: AnyComponent }>,
|
|
): LazyExoticComponent<AnyComponent> {
|
|
return lazy(async () => {
|
|
let lastError: unknown;
|
|
|
|
// 1) Retry the import with backoff — handles a transient 504 with no reload.
|
|
for (let attempt = 0; attempt < IMPORT_ATTEMPTS; attempt++) {
|
|
try {
|
|
const module = await factory();
|
|
// A clean load means the module graph is healthy again.
|
|
clearReloadMark();
|
|
return module;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isChunkLoadError(error)) throw error;
|
|
if (attempt < IMPORT_ATTEMPTS - 1) await sleep(400 * (attempt + 1));
|
|
}
|
|
}
|
|
|
|
// 2) Still failing → reload, if we haven't reloaded within the cooldown.
|
|
if (canReloadNow()) {
|
|
markReload();
|
|
window.location.reload();
|
|
// Suspend this import until the new document takes over.
|
|
return await new Promise<never>(() => undefined);
|
|
}
|
|
|
|
// 3) Reloaded recently and it's still broken → let the error boundary show.
|
|
throw lastError;
|
|
});
|
|
}
|