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
This commit is contained in:
parent
1577ddc86f
commit
4e17852df3
3 changed files with 150 additions and 54 deletions
|
|
@ -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 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.**
|
- `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)
|
### ⚠️ 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).
|
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".
|
- **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".
|
||||||
|
|
|
||||||
|
|
@ -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 { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { ScrollToTop } from "./components/ScrollToTop";
|
import { ScrollToTop } from "./components/ScrollToTop";
|
||||||
import { BECOME_ROLES } from "./lib/becomeRoles";
|
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
|
// The map is the homepage, but lazy-loading it keeps its chunk (plus the
|
||||||
// IndexedDB offline-cache layer) out of the initial entry bundle.
|
// 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
|
// Lazy load everything else — each page becomes its own JS chunk
|
||||||
const Index = lazy(() => import("./pages/Index"));
|
const Index = lazyWithRetry(() => import("./pages/Index"));
|
||||||
const EventsPage = lazy(() => import("./pages/EventsPage"));
|
const EventsPage = lazyWithRetry(() => import("./pages/EventsPage"));
|
||||||
const LabPage = lazy(() => import("./pages/LabPage"));
|
const LabPage = lazyWithRetry(() => import("./pages/LabPage"));
|
||||||
const WalletPage = lazy(() => import("./pages/WalletPage"));
|
const WalletPage = lazyWithRetry(() => import("./pages/WalletPage"));
|
||||||
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
||||||
const CreateSpotPage = lazy(() => import("./pages/CreateSpotPage"));
|
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
||||||
const CreateShopPage = lazy(() => import("./pages/CreateShopPage"));
|
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
|
||||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage"));
|
const CreateEventPage = lazyWithRetry(() => import("./pages/CreateEventPage"));
|
||||||
const CreateListingPage = lazy(() => import("./pages/CreateListingPage"));
|
const CreateListingPage = lazyWithRetry(() => import("./pages/CreateListingPage"));
|
||||||
const CreateTourPage = lazy(() => import("./pages/CreateTourPage"));
|
const CreateTourPage = lazyWithRetry(() => import("./pages/CreateTourPage"));
|
||||||
const EditSpotPage = lazy(() => import("./pages/EditSpotPage"));
|
const EditSpotPage = lazyWithRetry(() => import("./pages/EditSpotPage"));
|
||||||
const EditShopPage = lazy(() => import("./pages/EditShopPage"));
|
const EditShopPage = lazyWithRetry(() => import("./pages/EditShopPage"));
|
||||||
const EditEventPage = lazy(() => import("./pages/EditEventPage"));
|
const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage"));
|
||||||
const EditTourPage = lazy(() => import("./pages/EditTourPage"));
|
const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage"));
|
||||||
const GearSwapPage = lazy(() => import("./pages/GearSwapPage"));
|
const GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage"));
|
||||||
const SpotDetailPage = lazy(() => import("./pages/SpotDetailPage"));
|
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
||||||
const ShopDetailPage = lazy(() => import("./pages/ShopDetailPage"));
|
const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage"));
|
||||||
const EventDetailPage = lazy(() => import("./pages/EventDetailPage"));
|
const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage"));
|
||||||
const OnboardingPage = lazy(() => import("./pages/OnboardingPage"));
|
const OnboardingPage = lazyWithRetry(() => import("./pages/OnboardingPage"));
|
||||||
const PyramidPage = lazy(() => import("./pages/PyramidPage"));
|
const PyramidPage = lazyWithRetry(() => import("./pages/PyramidPage"));
|
||||||
// All "become a <role>" flows now share a single param route backed by a role
|
// All "become a <role>" flows now share a single param route backed by a role
|
||||||
// registry (see src/lib/becomeRoles.tsx) instead of 12 separate route lines.
|
// registry (see src/lib/becomeRoles.tsx) instead of 12 separate route lines.
|
||||||
const BecomeRolePage = lazy(() => import("./pages/BecomeRolePage"));
|
const BecomeRolePage = lazyWithRetry(() => import("./pages/BecomeRolePage"));
|
||||||
const SkatersPage = lazy(() => import("./pages/SkatersPage"));
|
const SkatersPage = lazyWithRetry(() => import("./pages/SkatersPage"));
|
||||||
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
|
const CrewsPage = lazyWithRetry(() => import("./pages/CrewsPage"));
|
||||||
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
|
const CreateCrewPage = lazyWithRetry(() => import("./pages/CreateCrewPage"));
|
||||||
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
|
const EditCrewPage = lazyWithRetry(() => import("./pages/EditCrewPage"));
|
||||||
const CrewDetailPage = lazy(() => import("./pages/CrewDetailPage"));
|
const CrewDetailPage = lazyWithRetry(() => import("./pages/CrewDetailPage"));
|
||||||
const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
|
const CoachDetailPage = lazyWithRetry(() => import("./pages/CoachDetailPage"));
|
||||||
const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
|
const TourDetailPage = lazyWithRetry(() => import("./pages/TourDetailPage"));
|
||||||
const AboutPage = lazy(() => import("./pages/AboutPage"));
|
const AboutPage = lazyWithRetry(() => import("./pages/AboutPage"));
|
||||||
const FAQPage = lazy(() => import("./pages/FAQPage"));
|
const FAQPage = lazyWithRetry(() => import("./pages/FAQPage"));
|
||||||
const FoodPage = lazy(() => import("./pages/FoodPage"));
|
const FoodPage = lazyWithRetry(() => import("./pages/FoodPage"));
|
||||||
const CommunityStatsPage = lazy(() => import("./pages/CommunityStatsPage"));
|
const CommunityStatsPage = lazyWithRetry(() => import("./pages/CommunityStatsPage"));
|
||||||
const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage"));
|
const SpotCollectionsPage = lazyWithRetry(() => import("./pages/SpotCollectionsPage"));
|
||||||
const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage"));
|
const CollectionDetailPage = lazyWithRetry(() => import("./pages/CollectionDetailPage"));
|
||||||
const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage"));
|
const DirectMessagesPage = lazyWithRetry(() => import("./pages/DirectMessagesPage"));
|
||||||
const ConversationPage = lazy(() => import("./pages/ConversationPage"));
|
const ConversationPage = lazyWithRetry(() => import("./pages/ConversationPage"));
|
||||||
const GroupConversationPage = lazy(() => import("./pages/GroupConversationPage"));
|
const GroupConversationPage = lazyWithRetry(() => import("./pages/GroupConversationPage"));
|
||||||
const ContactsPage = lazy(() => import("./pages/ContactsPage"));
|
const ContactsPage = lazyWithRetry(() => import("./pages/ContactsPage"));
|
||||||
const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage"));
|
const BountyLeaderboardPage = lazyWithRetry(() => import("./pages/BountyLeaderboardPage"));
|
||||||
const ZapLeaderboardPage = lazy(() => import("./pages/ZapLeaderboardPage"));
|
const ZapLeaderboardPage = lazyWithRetry(() => import("./pages/ZapLeaderboardPage"));
|
||||||
const SignersPage = lazy(() => import("./pages/SignersPage"));
|
const SignersPage = lazyWithRetry(() => import("./pages/SignersPage"));
|
||||||
const StashPage = lazy(() => import("./pages/StashPage"));
|
const StashPage = lazyWithRetry(() => import("./pages/StashPage"));
|
||||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||||
const LicensePage = lazy(() => import("./pages/LicensePage"));
|
const LicensePage = lazyWithRetry(() => import("./pages/LicensePage"));
|
||||||
const PrivacyPage = lazy(() => import("./pages/PrivacyPage"));
|
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
|
||||||
const SupportersPage = lazy(() => import("./pages/SupportersPage"));
|
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
|
||||||
const RiderCardPage = lazy(() => import("./pages/RiderCardPage"));
|
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
||||||
const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage"));
|
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
||||||
const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
|
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
|
||||||
const NewsletterPage = lazy(() => import("./pages/NewsletterPage"));
|
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
||||||
const GlossaryPage = lazy(() => import("./pages/GlossaryPage"));
|
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
|
||||||
|
|
||||||
// Minimal inline fallback — no extra component needed
|
// Minimal inline fallback — no extra component needed
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
|
|
|
||||||
89
src/lib/lazyWithRetry.ts
Normal file
89
src/lib/lazyWithRetry.ts
Normal file
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue