From fb16ea74c04a362a52ef977aca5e4d7eb2193ae7 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 23:37:46 +0700 Subject: [PATCH] fix(dev): self-healing lazyWithRetry (retries + cooldown reload); add dev:fresh; auto-restart dev on dep changes --- AGENTS.md | 5 ++- package.json | 1 + src/lib/lazyWithRetry.ts | 86 ++++++++++++++++++++++++---------------- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 163f289..f4217b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/package.json b/package.json index 975244d..c1e61bc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/lazyWithRetry.ts b/src/lib/lazyWithRetry.ts index 5a61500..4169d9a 100644 --- a/src/lib/lazyWithRetry.ts +++ b/src/lib/lazyWithRetry.ts @@ -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 { + 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 { 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(() => 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(() => undefined); + } + + // 3) Reloaded recently and it's still broken → let the error boundary show. + throw lastError; }); }