From 4e17852df34ddece7acbef0eafbf1dae2a4a9ff7 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 20:36:14 +0700 Subject: [PATCH] fix(routes): self-healing lazy routes; document dev dep-cache crash 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 --- AGENTS.md | 6 +++ src/AppRouter.tsx | 109 ++++++++++++++++++++------------------- src/lib/lazyWithRetry.ts | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 54 deletions(-) create mode 100644 src/lib/lazyWithRetry.ts diff --git a/AGENTS.md b/AGENTS.md index 4fdd9d9..d8cd0c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,12 @@ Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngi - `npm run dev` → http://localhost:8080. Note the script runs `npm i` first (see lockfile caveat). - `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.** +### ⚠️ "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. + ### ⚠️ 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). - **Regression symptoms**: `npx cap` → "could not determine executable to run"; `tsc` → "Module '@nostrify/nostrify' has no exported member NostrEvent"; Vite → "Could not resolve @std/encoding/hex". diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 4ec597d..06ba128 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense } from "react"; +import { Suspense } from "react"; +import { lazyWithRetry } from "./lib/lazyWithRetry"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import { BECOME_ROLES } from "./lib/becomeRoles"; @@ -7,63 +8,63 @@ import { RouteErrorBoundary } from "./components/RouteErrorBoundary"; // The map is the homepage, but lazy-loading it keeps its chunk (plus the // IndexedDB offline-cache layer) out of the initial entry bundle. -const MapPage = lazy(() => import("./pages/MapPage")); +const MapPage = lazyWithRetry(() => import("./pages/MapPage")); // Lazy load everything else — each page becomes its own JS chunk -const Index = lazy(() => import("./pages/Index")); -const EventsPage = lazy(() => import("./pages/EventsPage")); -const LabPage = lazy(() => import("./pages/LabPage")); -const WalletPage = lazy(() => import("./pages/WalletPage")); -const SettingsPage = lazy(() => import("./pages/SettingsPage")); -const CreateSpotPage = lazy(() => import("./pages/CreateSpotPage")); -const CreateShopPage = lazy(() => import("./pages/CreateShopPage")); -const CreateEventPage = lazy(() => import("./pages/CreateEventPage")); -const CreateListingPage = lazy(() => import("./pages/CreateListingPage")); -const CreateTourPage = lazy(() => import("./pages/CreateTourPage")); -const EditSpotPage = lazy(() => import("./pages/EditSpotPage")); -const EditShopPage = lazy(() => import("./pages/EditShopPage")); -const EditEventPage = lazy(() => import("./pages/EditEventPage")); -const EditTourPage = lazy(() => import("./pages/EditTourPage")); -const GearSwapPage = lazy(() => import("./pages/GearSwapPage")); -const SpotDetailPage = lazy(() => import("./pages/SpotDetailPage")); -const ShopDetailPage = lazy(() => import("./pages/ShopDetailPage")); -const EventDetailPage = lazy(() => import("./pages/EventDetailPage")); -const OnboardingPage = lazy(() => import("./pages/OnboardingPage")); -const PyramidPage = lazy(() => import("./pages/PyramidPage")); +const Index = lazyWithRetry(() => import("./pages/Index")); +const EventsPage = lazyWithRetry(() => import("./pages/EventsPage")); +const LabPage = lazyWithRetry(() => import("./pages/LabPage")); +const WalletPage = lazyWithRetry(() => import("./pages/WalletPage")); +const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); +const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); +const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); +const CreateEventPage = lazyWithRetry(() => import("./pages/CreateEventPage")); +const CreateListingPage = lazyWithRetry(() => import("./pages/CreateListingPage")); +const CreateTourPage = lazyWithRetry(() => import("./pages/CreateTourPage")); +const EditSpotPage = lazyWithRetry(() => import("./pages/EditSpotPage")); +const EditShopPage = lazyWithRetry(() => import("./pages/EditShopPage")); +const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage")); +const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage")); +const GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage")); +const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage")); +const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage")); +const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage")); +const OnboardingPage = lazyWithRetry(() => import("./pages/OnboardingPage")); +const PyramidPage = lazyWithRetry(() => import("./pages/PyramidPage")); // All "become a " flows now share a single param route backed by a role // registry (see src/lib/becomeRoles.tsx) instead of 12 separate route lines. -const BecomeRolePage = lazy(() => import("./pages/BecomeRolePage")); -const SkatersPage = lazy(() => import("./pages/SkatersPage")); -const CrewsPage = lazy(() => import("./pages/CrewsPage")); -const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage")); -const EditCrewPage = lazy(() => import("./pages/EditCrewPage")); -const CrewDetailPage = lazy(() => import("./pages/CrewDetailPage")); -const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage")); -const TourDetailPage = lazy(() => import("./pages/TourDetailPage")); -const AboutPage = lazy(() => import("./pages/AboutPage")); -const FAQPage = lazy(() => import("./pages/FAQPage")); -const FoodPage = lazy(() => import("./pages/FoodPage")); -const CommunityStatsPage = lazy(() => import("./pages/CommunityStatsPage")); -const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage")); -const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage")); -const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage")); -const ConversationPage = lazy(() => import("./pages/ConversationPage")); -const GroupConversationPage = lazy(() => import("./pages/GroupConversationPage")); -const ContactsPage = lazy(() => import("./pages/ContactsPage")); -const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage")); -const ZapLeaderboardPage = lazy(() => import("./pages/ZapLeaderboardPage")); -const SignersPage = lazy(() => import("./pages/SignersPage")); -const StashPage = lazy(() => import("./pages/StashPage")); -const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page }))); -const LicensePage = lazy(() => import("./pages/LicensePage")); -const PrivacyPage = lazy(() => import("./pages/PrivacyPage")); -const SupportersPage = lazy(() => import("./pages/SupportersPage")); -const RiderCardPage = lazy(() => import("./pages/RiderCardPage")); -const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage")); -const MarketplacePage = lazy(() => import("./pages/MarketplacePage")); -const NewsletterPage = lazy(() => import("./pages/NewsletterPage")); -const GlossaryPage = lazy(() => import("./pages/GlossaryPage")); -const NotFound = lazy(() => import("./pages/NotFound")); +const BecomeRolePage = lazyWithRetry(() => import("./pages/BecomeRolePage")); +const SkatersPage = lazyWithRetry(() => import("./pages/SkatersPage")); +const CrewsPage = lazyWithRetry(() => import("./pages/CrewsPage")); +const CreateCrewPage = lazyWithRetry(() => import("./pages/CreateCrewPage")); +const EditCrewPage = lazyWithRetry(() => import("./pages/EditCrewPage")); +const CrewDetailPage = lazyWithRetry(() => import("./pages/CrewDetailPage")); +const CoachDetailPage = lazyWithRetry(() => import("./pages/CoachDetailPage")); +const TourDetailPage = lazyWithRetry(() => import("./pages/TourDetailPage")); +const AboutPage = lazyWithRetry(() => import("./pages/AboutPage")); +const FAQPage = lazyWithRetry(() => import("./pages/FAQPage")); +const FoodPage = lazyWithRetry(() => import("./pages/FoodPage")); +const CommunityStatsPage = lazyWithRetry(() => import("./pages/CommunityStatsPage")); +const SpotCollectionsPage = lazyWithRetry(() => import("./pages/SpotCollectionsPage")); +const CollectionDetailPage = lazyWithRetry(() => import("./pages/CollectionDetailPage")); +const DirectMessagesPage = lazyWithRetry(() => import("./pages/DirectMessagesPage")); +const ConversationPage = lazyWithRetry(() => import("./pages/ConversationPage")); +const GroupConversationPage = lazyWithRetry(() => import("./pages/GroupConversationPage")); +const ContactsPage = lazyWithRetry(() => import("./pages/ContactsPage")); +const BountyLeaderboardPage = lazyWithRetry(() => import("./pages/BountyLeaderboardPage")); +const ZapLeaderboardPage = lazyWithRetry(() => import("./pages/ZapLeaderboardPage")); +const SignersPage = lazyWithRetry(() => import("./pages/SignersPage")); +const StashPage = lazyWithRetry(() => import("./pages/StashPage")); +const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page }))); +const LicensePage = lazyWithRetry(() => import("./pages/LicensePage")); +const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); +const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); +const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); +const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage")); +const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage")); +const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage")); +const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage")); +const NotFound = lazyWithRetry(() => import("./pages/NotFound")); // Minimal inline fallback — no extra component needed function PageLoader() { diff --git a/src/lib/lazyWithRetry.ts b/src/lib/lazyWithRetry.ts new file mode 100644 index 0000000..5a61500 --- /dev/null +++ b/src/lib/lazyWithRetry.ts @@ -0,0 +1,89 @@ +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` (rather than `any`) keeps this type-safe while still + * accepting every page component in the router. + */ +type AnyComponent = ComponentType; + +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 { + 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(() => 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; + } + }); +}