Symptom: in the long-running dev server some lazy routes (/settings, /stash,
/collections, /stats) hit the route error boundary ('This page slammed') while
others worked. Console: 'Failed to fetch dynamically imported module'; network:
504 for node_modules/.vite/deps/*.js. Vite's dep-optimizer cache had gone
missing after the server re-optimized on a lockfile change, leaving stale ?v=
hashes.
- Add lazyWithRetry: on a chunk-load failure, retry once, then do a single
full reload per session (avoids reload loops); clears the flag on success
- Use it for all 53 lazy routes in AppRouter
- Document the dev dep-cache gotcha + fix in AGENTS.md
89 lines
3 KiB
TypeScript
89 lines
3 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.
|
|
*
|
|
* Instead of failing hard we retry once, and if it still fails we do a single
|
|
* full reload per session (which fetches a fresh index + module graph). The
|
|
* session flag prevents a reload loop if the failure is genuinely persistent —
|
|
* then the error boundary is the correct last resort.
|
|
*/
|
|
const RELOAD_FLAG = 'skatespots:chunk-reload';
|
|
|
|
/**
|
|
* 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 hasReloadedThisSession(): boolean {
|
|
try {
|
|
return sessionStorage.getItem(RELOAD_FLAG) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function markReloaded(): void {
|
|
try {
|
|
sessionStorage.setItem(RELOAD_FLAG, '1');
|
|
} catch {
|
|
/* sessionStorage unavailable — the retry below still runs */
|
|
}
|
|
}
|
|
|
|
function clearReloadFlag(): void {
|
|
try {
|
|
sessionStorage.removeItem(RELOAD_FLAG);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function lazyWithRetry(
|
|
factory: () => Promise<{ default: AnyComponent }>,
|
|
): LazyExoticComponent<AnyComponent> {
|
|
return lazy(async () => {
|
|
try {
|
|
const module = await factory();
|
|
// A clean load means the module graph is healthy again; allow a future
|
|
// (unrelated) failure to trigger its own reload.
|
|
clearReloadFlag();
|
|
return module;
|
|
} catch (error) {
|
|
if (!isChunkLoadError(error)) throw error;
|
|
|
|
if (!hasReloadedThisSession()) {
|
|
markReloaded();
|
|
// Kick a full reload and suspend this import until the new document
|
|
// takes over. Never resolves — the navigation unloads the page.
|
|
window.location.reload();
|
|
return await new Promise<never>(() => undefined);
|
|
}
|
|
|
|
// Already reloaded once this session. The optimizer or CDN may have just
|
|
// needed a moment, so retry the import before showing the error boundary.
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
const module = await factory();
|
|
clearReloadFlag();
|
|
return module;
|
|
}
|
|
});
|
|
}
|