fix(dev): self-healing lazyWithRetry (retries + cooldown reload); add dev:fresh; auto-restart dev on dep changes
This commit is contained in:
parent
cc50eaad18
commit
fb16ea74c0
3 changed files with 56 additions and 36 deletions
|
|
@ -1842,8 +1842,9 @@ Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngi
|
|||
### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache)
|
||||
Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`.
|
||||
- **Cause**: Vite's dependency optimizer cache (`node_modules/.vite/deps`) went missing/was invalidated while the dev server stayed up (long-running servers re-optimize whenever the lockfile changes — and every `npm i`, which `dev`/`test`/`build` run, touches `package-lock.json`). The server keeps serving stale `?v=` hashes → 504 → `React.lazy` rejects.
|
||||
- **Fix**: restart the dev server (`rm -rf node_modules/.vite` then `npm run dev`) and hard-refresh the browser. `npm i` / `vite build` / `vitest` alone do **not** wipe the cache — it's the long-running server's re-optimize that races.
|
||||
- **Resilience**: all routes use `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) instead of bare `lazy()`. On a chunk-load failure it retries once, then performs a single full page reload per session, so a transient optimizer/deploy hiccup self-heals instead of dead-ending on the error boundary.
|
||||
- **Fix**: `npm run dev:fresh` (clears `node_modules/.vite` then starts dev) — or, for the running systemd service, `rm -rf node_modules/.vite && systemctl --user restart skatespots-dev` — then **hard-refresh the browser** (`Ctrl+Shift+R`). `npm i` / `vite build` / `vitest` alone do **not** wipe the cache — it's the long-running server's re-optimize that races.
|
||||
- **Prevention**: after any command that runs `npm i` (`npm test`, `npm run build`, `npm run dev`, or a version bump), **restart the dev server** before browsing — the re-optimized cache writes fresh `?v=` hashes, so an open tab stops 504-ing. Don't leave a dev server running across a build.
|
||||
- **Resilience — self-heals now (2026-10-05)**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`). On a chunk-load failure it **retries the import 3× with backoff**, then does a **cooldown-throttled full reload** (>10 s since the last one), so *repeated* stale-hash failures each recover **without a manual refresh**. ⚠️ **Do NOT reintroduce a once-per-session reload cap** — the old version reloaded once per session, so the *second* failure dead-ended on "This page slammed" and read as an app bug. Only a genuinely persistent failure should reach the error boundary.
|
||||
|
||||
### ⚠️ Lockfile caveat (important)
|
||||
The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped of `dependencies`/`bin` metadata, so transitive packages were silently missing. Repaired in `eb23ebc` (654 → 902 packages).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"homepage": "https://skate-spots.org",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"dev:fresh": "rm -rf node_modules/.vite && npm run dev",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"deploy:cvm": "bash scripts/deploy-cvm.sh",
|
||||
"deploy:ai": "bash scripts/deploy-ai-proxy.sh",
|
||||
|
|
|
|||
|
|
@ -12,12 +12,22 @@ import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
|
|||
* 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.
|
||||
* 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_FLAG = 'skatespots:chunk-reload';
|
||||
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
|
||||
|
|
@ -33,25 +43,31 @@ function isChunkLoadError(error: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function hasReloadedThisSession(): boolean {
|
||||
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 {
|
||||
return sessionStorage.getItem(RELOAD_FLAG) === '1';
|
||||
const last = Number(sessionStorage.getItem(RELOAD_TS_KEY) || 0);
|
||||
return !Number.isFinite(last) || Date.now() - last > RELOAD_COOLDOWN_MS;
|
||||
} catch {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markReloaded(): void {
|
||||
function markReload(): void {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_FLAG, '1');
|
||||
sessionStorage.setItem(RELOAD_TS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* sessionStorage unavailable — the retry below still runs */
|
||||
/* sessionStorage unavailable — the caller's reload still runs */
|
||||
}
|
||||
}
|
||||
|
||||
function clearReloadFlag(): void {
|
||||
function clearReloadMark(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(RELOAD_FLAG);
|
||||
sessionStorage.removeItem(RELOAD_TS_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
@ -61,29 +77,31 @@ 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;
|
||||
let lastError: unknown;
|
||||
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue