perf: lazy loading, faster timeouts, better caching, chunk splitting

- AppRouter: lazy load all 40+ pages (only Index is eager)
  → initial JS bundle no longer includes code for pages never visited

- MapPage: lazy load SpotMap/Leaflet (~150KB) behind Suspense
  → Leaflet only downloads when user navigates to /map

- useSkateSpots/useShops/useMarketplace/useTours:
  → timeout: 30s → 8s (fail fast, show error sooner)
  → staleTime: 30s → 5min (trust cache, fewer relay round-trips)
  → gcTime: 5min → 30min (keep data in memory longer)
  → refetchOnMount: 'always' → true (use cache when data is fresh)

- Remove console.log/warn/debug spam from production hooks

- vite.config: manual chunk splitting for leaflet, nostr-tools, bitcoin
  libs, radix-ui, react-router — each chunk cached independently
  by browser, only re-downloaded when that library updates

- vite.config: esbuild drops console.log/warn/debug in prod build

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 11:32:24 +07:00
parent d844e151bd
commit bd3e4bee4e
7 changed files with 258 additions and 226 deletions

View file

@ -1,106 +1,124 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop"; import { ScrollToTop } from "./components/ScrollToTop";
// Eagerly load only the most-visited pages
import Index from "./pages/Index"; import Index from "./pages/Index";
import MapPage from "./pages/MapPage";
import EventsPage from "./pages/EventsPage"; // Lazy load everything else — each page becomes its own JS chunk
import FoodPage from "./pages/FoodPage"; const MapPage = lazy(() => import("./pages/MapPage"));
import WalletPage from "./pages/WalletPage"; const EventsPage = lazy(() => import("./pages/EventsPage"));
import SettingsPage from "./pages/SettingsPage"; const LabPage = lazy(() => import("./pages/LabPage"));
import CreateSpotPage from "./pages/CreateSpotPage"; const WalletPage = lazy(() => import("./pages/WalletPage"));
import CreateShopPage from "./pages/CreateShopPage"; const SettingsPage = lazy(() => import("./pages/SettingsPage"));
import CreateEventPage from "./pages/CreateEventPage"; const CreateSpotPage = lazy(() => import("./pages/CreateSpotPage"));
import CreateListingPage from "./pages/CreateListingPage"; const CreateShopPage = lazy(() => import("./pages/CreateShopPage"));
import CreateTourPage from "./pages/CreateTourPage"; const CreateEventPage = lazy(() => import("./pages/CreateEventPage"));
import EditSpotPage from "./pages/EditSpotPage"; const CreateListingPage = lazy(() => import("./pages/CreateListingPage"));
import EditShopPage from "./pages/EditShopPage"; const CreateTourPage = lazy(() => import("./pages/CreateTourPage"));
import EditEventPage from "./pages/EditEventPage"; const EditSpotPage = lazy(() => import("./pages/EditSpotPage"));
import GearSwapPage from "./pages/GearSwapPage"; const EditShopPage = lazy(() => import("./pages/EditShopPage"));
import SpotDetailPage from "./pages/SpotDetailPage"; const EditEventPage = lazy(() => import("./pages/EditEventPage"));
import ShopDetailPage from "./pages/ShopDetailPage"; const GearSwapPage = lazy(() => import("./pages/GearSwapPage"));
import EventDetailPage from "./pages/EventDetailPage"; const SpotDetailPage = lazy(() => import("./pages/SpotDetailPage"));
import OnboardingPage from "./pages/OnboardingPage"; const ShopDetailPage = lazy(() => import("./pages/ShopDetailPage"));
import PyramidPage from "./pages/PyramidPage"; const EventDetailPage = lazy(() => import("./pages/EventDetailPage"));
import LabPage from "./pages/LabPage"; const OnboardingPage = lazy(() => import("./pages/OnboardingPage"));
import BecomeCoachPage from "./pages/BecomeCoachPage"; const PyramidPage = lazy(() => import("./pages/PyramidPage"));
import BecomeFilmerPage from "./pages/BecomeFilmerPage"; const BecomeCoachPage = lazy(() => import("./pages/BecomeCoachPage"));
import BecomeEditorPage from "./pages/BecomeEditorPage"; const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
import CoachDetailPage from "./pages/CoachDetailPage"; const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
import TourDetailPage from "./pages/TourDetailPage"; const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
import AboutPage from "./pages/AboutPage"; const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
import FAQPage from "./pages/FAQPage"; const AboutPage = lazy(() => import("./pages/AboutPage"));
import CommunityStatsPage from "./pages/CommunityStatsPage"; const FAQPage = lazy(() => import("./pages/FAQPage"));
import SpotCollectionsPage from "./pages/SpotCollectionsPage"; const FoodPage = lazy(() => import("./pages/FoodPage"));
import CollectionDetailPage from "./pages/CollectionDetailPage"; const CommunityStatsPage = lazy(() => import("./pages/CommunityStatsPage"));
import DirectMessagesPage from "./pages/DirectMessagesPage"; const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage"));
import ConversationPage from "./pages/ConversationPage"; const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage"));
import BountyLeaderboardPage from "./pages/BountyLeaderboardPage"; const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage"));
import SignersPage from "./pages/SignersPage"; const ConversationPage = lazy(() => import("./pages/ConversationPage"));
import StashPage from "./pages/StashPage"; const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage"));
import { NIP19Page } from "./pages/NIP19Page"; const SignersPage = lazy(() => import("./pages/SignersPage"));
import LicensePage from "./pages/LicensePage"; const StashPage = lazy(() => import("./pages/StashPage"));
import SupportersPage from "./pages/SupportersPage"; const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
import RiderCardPage from "./pages/RiderCardPage"; const LicensePage = lazy(() => import("./pages/LicensePage"));
import AmberCallbackPage from "./pages/AmberCallbackPage"; const SupportersPage = lazy(() => import("./pages/SupportersPage"));
import NotFound from "./pages/NotFound"; const RiderCardPage = lazy(() => import("./pages/RiderCardPage"));
const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage"));
const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
const NotFound = lazy(() => import("./pages/NotFound"));
// Minimal inline fallback — no extra component needed
function PageLoader() {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" />
</div>
);
}
export function AppRouter() { export function AppRouter() {
return ( return (
<BrowserRouter> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<Routes> <Suspense fallback={<PageLoader />}>
<Route path="/" element={<Index />} /> <Routes>
<Route path="/map" element={<MapPage />} /> <Route path="/" element={<Index />} />
<Route path="/events" element={<EventsPage />} /> <Route path="/map" element={<MapPage />} />
<Route path="/lab" element={<LabPage />} /> <Route path="/events" element={<EventsPage />} />
{/* Legacy route redirect */} <Route path="/lab" element={<LabPage />} />
<Route path="/coaches" element={<LabPage />} /> {/* Legacy route redirect */}
<Route path="/become-coach" element={<BecomeCoachPage />} /> <Route path="/coaches" element={<LabPage />} />
<Route path="/become-filmer" element={<BecomeFilmerPage />} /> <Route path="/become-coach" element={<BecomeCoachPage />} />
<Route path="/become-editor" element={<BecomeEditorPage />} /> <Route path="/become-filmer" element={<BecomeFilmerPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} /> <Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/onboarding" element={<OnboardingPage />} /> <Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/pyramid" element={<PyramidPage />} /> <Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/marketplace" element={<GearSwapPage />} /> <Route path="/pyramid" element={<PyramidPage />} />
<Route path="/stats" element={<CommunityStatsPage />} /> <Route path="/marketplace" element={<GearSwapPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/stats" element={<CommunityStatsPage />} />
<Route path="/faq" element={<FAQPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/food" element={<FoodPage />} /> <Route path="/faq" element={<FAQPage />} />
<Route path="/wallet" element={<WalletPage />} /> <Route path="/food" element={<FoodPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/wallet" element={<WalletPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/create-shop" element={<CreateShopPage />} /> <Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-event" element={<CreateEventPage />} /> <Route path="/create-shop" element={<CreateShopPage />} />
<Route path="/create-listing" element={<CreateListingPage />} /> <Route path="/create-event" element={<CreateEventPage />} />
<Route path="/create-tour" element={<CreateTourPage />} /> <Route path="/create-listing" element={<CreateListingPage />} />
<Route path="/edit-spot/:pubkey/:identifier" element={<EditSpotPage />} /> <Route path="/create-tour" element={<CreateTourPage />} />
<Route path="/edit-shop/:pubkey/:identifier" element={<EditShopPage />} /> <Route path="/edit-spot/:pubkey/:identifier" element={<EditSpotPage />} />
<Route path="/edit-event/:pubkey/:identifier" element={<EditEventPage />} /> <Route path="/edit-shop/:pubkey/:identifier" element={<EditShopPage />} />
<Route path="/spot/:pubkey/:identifier" element={<SpotDetailPage />} /> <Route path="/edit-event/:pubkey/:identifier" element={<EditEventPage />} />
<Route path="/shop/:pubkey/:identifier" element={<ShopDetailPage />} /> <Route path="/spot/:pubkey/:identifier" element={<SpotDetailPage />} />
<Route path="/event/:pubkey/:identifier" element={<EventDetailPage />} /> <Route path="/shop/:pubkey/:identifier" element={<ShopDetailPage />} />
<Route path="/tour/:pubkey/:identifier" element={<TourDetailPage />} /> <Route path="/event/:pubkey/:identifier" element={<EventDetailPage />} />
<Route path="/collections" element={<SpotCollectionsPage />} /> <Route path="/tour/:pubkey/:identifier" element={<TourDetailPage />} />
<Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} /> <Route path="/collections" element={<SpotCollectionsPage />} />
<Route path="/messages" element={<DirectMessagesPage />} /> <Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} />
<Route path="/messages/:npub" element={<ConversationPage />} /> <Route path="/messages" element={<DirectMessagesPage />} />
{/* Bounty System */} <Route path="/messages/:npub" element={<ConversationPage />} />
<Route path="/bounties" element={<BountyLeaderboardPage />} /> {/* Bounty System */}
{/* Security & Signers */} <Route path="/bounties" element={<BountyLeaderboardPage />} />
<Route path="/signers" element={<SignersPage />} /> {/* Security & Signers */}
{/* Stash - Private Inventory */} <Route path="/signers" element={<SignersPage />} />
<Route path="/stash" element={<StashPage />} /> {/* Stash - Private Inventory */}
{/* Info pages */} <Route path="/stash" element={<StashPage />} />
<Route path="/card" element={<RiderCardPage />} /> {/* Info pages */}
<Route path="/license" element={<LicensePage />} /> <Route path="/card" element={<RiderCardPage />} />
<Route path="/supporters" element={<SupportersPage />} /> <Route path="/license" element={<LicensePage />} />
{/* Amber NIP-55 callback — Amber redirects here after signing/login */} <Route path="/supporters" element={<SupportersPage />} />
<Route path="/amber-callback" element={<AmberCallbackPage />} /> {/* Amber NIP-55 callback — Amber redirects here after signing/login */}
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} <Route path="/amber-callback" element={<AmberCallbackPage />} />
<Route path="/:nip19" element={<NIP19Page />} /> {/* Marketplace */}
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} <Route path="/marketplace-listings" element={<MarketplacePage />} />
<Route path="*" element={<NotFound />} /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
</Routes> <Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
); );
} }

View file

@ -2,15 +2,17 @@ import { useQuery } from '@tanstack/react-query';
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types'; import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min
const TIMEOUT = 8000;
export function useMarketplaceItems(limit = 100) { export function useMarketplaceItems(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['marketplace-items', limit, getQueryKeySuffix()], queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(TIMEOUT);
console.log('[useMarketplaceItems] Querying for marketplace items...');
const events = await querySkateContent( const events = await querySkateContent(
[ [
@ -23,20 +25,17 @@ export function useMarketplaceItems(limit = 100) {
{ signal } { signal }
); );
console.log(`[useMarketplaceItems] Received ${events.length} events`);
const items: MarketplaceItem[] = []; const items: MarketplaceItem[] = [];
for (const event of events) { for (const event of events) {
const item = parseMarketplaceItem(event); const item = parseMarketplaceItem(event);
if (item) items.push(item); if (item) items.push(item);
} }
console.log(`[useMarketplaceItems] Parsed items: ${items.length}`);
return items.sort((a, b) => b.createdAt - a.createdAt); return items.sort((a, b) => b.createdAt - a.createdAt);
}, },
staleTime: 30000, staleTime: STALE_TIME,
refetchOnMount: 'always', gcTime: GC_TIME,
refetchOnMount: true,
}); });
} }
@ -46,13 +45,10 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
return useQuery({ return useQuery({
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()], queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(TIMEOUT);
// Build spot reference (naddr format without encoding)
const spotRef = `30818:${spotPubkey}:${spotIdentifier}`; const spotRef = `30818:${spotPubkey}:${spotIdentifier}`;
console.log(`[useMarketplaceAtSpot] Querying for items at spot: ${spotRef}`);
const events = await querySkateContent( const events = await querySkateContent(
[ [
{ {
@ -65,23 +61,19 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
{ signal } { signal }
); );
console.log(`[useMarketplaceAtSpot] Received ${events.length} events`);
const items: MarketplaceItem[] = []; const items: MarketplaceItem[] = [];
for (const event of events) { for (const event of events) {
const item = parseMarketplaceItem(event); const item = parseMarketplaceItem(event);
// Only show items that are active or don't have a status
if (item && (!item.status || item.status === 'active')) { if (item && (!item.status || item.status === 'active')) {
items.push(item); items.push(item);
} }
} }
console.log(`[useMarketplaceAtSpot] Parsed active items: ${items.length}`);
return items.sort((a, b) => b.createdAt - a.createdAt); return items.sort((a, b) => b.createdAt - a.createdAt);
}, },
enabled: !!spotPubkey && !!spotIdentifier, enabled: !!spotPubkey && !!spotIdentifier,
staleTime: 30000, staleTime: STALE_TIME,
refetchOnMount: 'always', gcTime: GC_TIME,
refetchOnMount: true,
}); });
} }

View file

@ -10,8 +10,6 @@ export function useShops(limit = 100) {
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
console.log('[useShops] Querying for shops');
// Query new shop kind (30017) // Query new shop kind (30017)
const newShops = await querySkateContent( const newShops = await querySkateContent(
[ [
@ -23,8 +21,6 @@ export function useShops(limit = 100) {
], ],
{ signal } { signal }
); );
console.log(`[useShops] Received ${newShops.length} new shops (kind 30017)`);
// Query old shops (kind 30818 with 'shop' type) for backward compatibility // Query old shops (kind 30818 with 'shop' type) for backward compatibility
const oldShops = await querySkateContent( const oldShops = await querySkateContent(
[ [
@ -36,7 +32,7 @@ export function useShops(limit = 100) {
], ],
{ signal } { signal }
); );
console.log(`[useShops] Received ${oldShops.length} old shops (kind 30818)`);
const allEvents = [...newShops, ...oldShops]; const allEvents = [...newShops, ...oldShops];
@ -72,19 +68,15 @@ export function useShops(limit = 100) {
if (shop) { if (shop) {
shops.push(shop); shops.push(shop);
} else {
console.warn('[useShops] Failed to parse event:', event);
} }
} }
console.log('[useShops] Parsed shops:', shops.length);
// Sort by created_at descending // Sort by created_at descending
return shops.sort((a, b) => b.createdAt - a.createdAt); return shops.sort((a, b) => b.createdAt - a.createdAt);
}, },
staleTime: 30000, staleTime: 5 * 60 * 1000,
gcTime: 300000, gcTime: 30 * 60 * 1000,
refetchOnMount: 'always', refetchOnMount: true,
}); });
} }
@ -96,8 +88,6 @@ export function useShop(identifier: string, pubkey?: string) {
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
console.log(`[useShop] Querying for shop: ${identifier}`);
// Query new shop format (kind 30017) // Query new shop format (kind 30017)
const newFilter: { const newFilter: {
kinds: number[]; kinds: number[];
@ -112,8 +102,6 @@ export function useShop(identifier: string, pubkey?: string) {
if (pubkey) newFilter.authors = [pubkey]; if (pubkey) newFilter.authors = [pubkey];
const newShops = await querySkateContent([newFilter], { signal }); const newShops = await querySkateContent([newFilter], { signal });
console.log(`[useShop] Received ${newShops.length} new shops`);
// Query old shop format (kind 30818 with 'shop' type) for backward compatibility // Query old shop format (kind 30818 with 'shop' type) for backward compatibility
const oldFilter: { const oldFilter: {
kinds: number[]; kinds: number[];
@ -130,14 +118,9 @@ export function useShop(identifier: string, pubkey?: string) {
if (pubkey) oldFilter.authors = [pubkey]; if (pubkey) oldFilter.authors = [pubkey];
const oldShops = await querySkateContent([oldFilter], { signal }); const oldShops = await querySkateContent([oldFilter], { signal });
console.log(`[useShop] Received ${oldShops.length} old shops`);
const allEvents = [...newShops, ...oldShops]; const allEvents = [...newShops, ...oldShops];
if (allEvents.length === 0) { if (allEvents.length === 0) return null;
console.warn(`[useShop] No events found for identifier: ${identifier}`);
return null;
}
// If multiple events, take the most recent // If multiple events, take the most recent
const mostRecent = allEvents.reduce((latest, current) => const mostRecent = allEvents.reduce((latest, current) =>
@ -174,8 +157,9 @@ export function useShop(identifier: string, pubkey?: string) {
return shop; return shop;
}, },
enabled: !!identifier, enabled: !!identifier,
staleTime: 30000, staleTime: 5 * 60 * 1000,
refetchOnMount: 'always', gcTime: 30 * 60 * 1000,
refetchOnMount: true,
}); });
} }
@ -208,7 +192,8 @@ export function useUserShops(pubkey: string) {
return shops.sort((a, b) => b.createdAt - a.createdAt); return shops.sort((a, b) => b.createdAt - a.createdAt);
}, },
enabled: !!pubkey, enabled: !!pubkey,
staleTime: 30000, staleTime: 5 * 60 * 1000,
refetchOnMount: 'always', gcTime: 30 * 60 * 1000,
refetchOnMount: true,
}); });
} }

View file

@ -3,6 +3,11 @@ import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes
const SPOTS_STALE_TIME = 5 * 60 * 1000; // 5 min
const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min
const QUERY_TIMEOUT = 8000; // 8s — fail fast, show error sooner
export function useSkateSpots(limit = 100) { export function useSkateSpots(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
@ -11,12 +16,11 @@ export function useSkateSpots(limit = 100) {
queryFn: async () => { queryFn: async () => {
// If offline, return cached spots // If offline, return cached spots
if (isOffline()) { if (isOffline()) {
console.log('[useSkateSpots] Offline, returning cached spots');
const cachedSpots = await getAllCachedSpots(); const cachedSpots = await getAllCachedSpots();
return cachedSpots.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); return cachedSpots.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
} }
const signal = AbortSignal.timeout(30000); const signal = AbortSignal.timeout(QUERY_TIMEOUT);
const events = await querySkateContent( const events = await querySkateContent(
[ [
@ -31,32 +35,26 @@ export function useSkateSpots(limit = 100) {
const spots: SkateSpot[] = []; const spots: SkateSpot[] = [];
const spotsWithEvents: Array<{ event: typeof events[0]; spot: SkateSpot }> = []; const spotsWithEvents: Array<{ event: typeof events[0]; spot: SkateSpot }> = [];
for (const event of events) { for (const event of events) {
const spot = parseSkateSpot(event); const spot = parseSkateSpot(event);
if (spot) { if (spot) {
spots.push(spot); spots.push(spot);
spotsWithEvents.push({ event, spot }); spotsWithEvents.push({ event, spot });
} else {
console.warn('[useSkateSpots] Failed to parse event:', event);
} }
} }
console.log('[useSkateSpots] Parsed spots:', spots.length);
// Cache spots in the background for offline use // Cache spots in the background for offline use
if (spotsWithEvents.length > 0) { if (spotsWithEvents.length > 0) {
cacheSpots(spotsWithEvents).catch(err => cacheSpots(spotsWithEvents).catch(() => {});
console.warn('[useSkateSpots] Failed to cache spots:', err)
);
} }
// Sort by created_at descending
return spots.sort((a, b) => b.createdAt - a.createdAt); return spots.sort((a, b) => b.createdAt - a.createdAt);
}, },
staleTime: 30000, staleTime: SPOTS_STALE_TIME,
gcTime: 300000, gcTime: SPOTS_GC_TIME,
refetchOnMount: 'always', // Don't force a relay round-trip on every navigation — use cache if fresh
refetchOnMount: true,
}); });
} }
@ -68,16 +66,12 @@ export function useSkateSpot(identifier: string, pubkey?: string) {
queryFn: async () => { queryFn: async () => {
// If offline, try to get from cache first // If offline, try to get from cache first
if (isOffline() && pubkey) { if (isOffline() && pubkey) {
console.log('[useSkateSpot] Offline, checking cache');
const { getCachedSpot } = await import('@/lib/offlineCache'); const { getCachedSpot } = await import('@/lib/offlineCache');
const cached = await getCachedSpot(pubkey, identifier); const cached = await getCachedSpot(pubkey, identifier);
if (cached) { if (cached) return cached;
console.log('[useSkateSpot] Found in cache:', identifier);
return cached;
}
} }
const signal = AbortSignal.timeout(30000); const signal = AbortSignal.timeout(QUERY_TIMEOUT);
const filter: { const filter: {
kinds: number[]; kinds: number[];
@ -96,26 +90,22 @@ export function useSkateSpot(identifier: string, pubkey?: string) {
const events = await querySkateContent([filter], { signal }); const events = await querySkateContent([filter], { signal });
if (events.length === 0) { if (events.length === 0) return null;
console.warn(`[useSkateSpot] No events found for identifier: ${identifier}`);
return null;
}
const spot = parseSkateSpot(events[0]); const spot = parseSkateSpot(events[0]);
// Cache the spot for offline use // Cache the spot for offline use
if (spot) { if (spot) {
const { cacheSpot } = await import('@/lib/offlineCache'); const { cacheSpot } = await import('@/lib/offlineCache');
cacheSpot(events[0], spot).catch(err => cacheSpot(events[0], spot).catch(() => {});
console.warn('[useSkateSpot] Failed to cache spot:', err)
);
} }
return spot; return spot;
}, },
enabled: !!identifier, enabled: !!identifier,
staleTime: 30000, staleTime: SPOTS_STALE_TIME,
refetchOnMount: 'always', gcTime: SPOTS_GC_TIME,
refetchOnMount: true,
}); });
} }
@ -125,7 +115,7 @@ export function useUserSkateSpots(pubkey: string) {
return useQuery({ return useQuery({
queryKey: ['user-skate-spots', pubkey, getQueryKeySuffix()], queryKey: ['user-skate-spots', pubkey, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(30000); const signal = AbortSignal.timeout(QUERY_TIMEOUT);
const events = await querySkateContent( const events = await querySkateContent(
[ [
@ -148,7 +138,8 @@ export function useUserSkateSpots(pubkey: string) {
return spots.sort((a, b) => b.createdAt - a.createdAt); return spots.sort((a, b) => b.createdAt - a.createdAt);
}, },
enabled: !!pubkey, enabled: !!pubkey,
staleTime: 30000, staleTime: SPOTS_STALE_TIME,
refetchOnMount: 'always', gcTime: SPOTS_GC_TIME,
refetchOnMount: true,
}); });
} }

View file

@ -2,15 +2,17 @@ import { useQuery } from '@tanstack/react-query';
import { SKATE_TOUR_KIND, parseSkateTour, type SkateTour } from '@/lib/types'; import { SKATE_TOUR_KIND, parseSkateTour, type SkateTour } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min
const TIMEOUT = 8000;
export function useTours(limit = 100) { export function useTours(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['skate-tours', limit, getQueryKeySuffix()], queryKey: ['skate-tours', limit, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(TIMEOUT);
console.log('[useTours] Querying for tours');
const events = await querySkateContent( const events = await querySkateContent(
[ [
@ -23,26 +25,17 @@ export function useTours(limit = 100) {
{ signal } { signal }
); );
console.log(`[useTours] Received ${events.length} events`);
const tours: SkateTour[] = []; const tours: SkateTour[] = [];
for (const event of events) { for (const event of events) {
const tour = parseSkateTour(event); const tour = parseSkateTour(event);
if (tour) { if (tour) tours.push(tour);
tours.push(tour);
} else {
console.warn('[useTours] Failed to parse event:', event);
}
} }
console.log('[useTours] Parsed tours:', tours.length);
// Sort by created_at descending
return tours.sort((a, b) => b.createdAt - a.createdAt); return tours.sort((a, b) => b.createdAt - a.createdAt);
}, },
staleTime: 30000, staleTime: STALE_TIME,
gcTime: 300000, gcTime: GC_TIME,
refetchOnMount: 'always', refetchOnMount: true,
}); });
} }
@ -52,7 +45,7 @@ export function useTour(identifier: string, pubkey?: string) {
return useQuery({ return useQuery({
queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()], queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(TIMEOUT);
const filter: { const filter: {
kinds: number[]; kinds: number[];
@ -69,22 +62,16 @@ export function useTour(identifier: string, pubkey?: string) {
filter.authors = [pubkey]; filter.authors = [pubkey];
} }
console.log(`[useTour] Querying for tour: ${identifier}`);
const events = await querySkateContent([filter], { signal }); const events = await querySkateContent([filter], { signal });
console.log(`[useTour] Received ${events.length} events`); if (events.length === 0) return null;
if (events.length === 0) {
console.warn(`[useTour] No events found for identifier: ${identifier}`);
return null;
}
return parseSkateTour(events[0]); return parseSkateTour(events[0]);
}, },
enabled: !!identifier, enabled: !!identifier,
staleTime: 30000, staleTime: STALE_TIME,
refetchOnMount: 'always', gcTime: GC_TIME,
refetchOnMount: true,
}); });
} }
@ -94,7 +81,7 @@ export function useUserTours(pubkey: string) {
return useQuery({ return useQuery({
queryKey: ['user-skate-tours', pubkey, getQueryKeySuffix()], queryKey: ['user-skate-tours', pubkey, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(TIMEOUT);
const events = await querySkateContent( const events = await querySkateContent(
[ [
@ -117,7 +104,8 @@ export function useUserTours(pubkey: string) {
return tours.sort((a, b) => b.createdAt - a.createdAt); return tours.sort((a, b) => b.createdAt - a.createdAt);
}, },
enabled: !!pubkey, enabled: !!pubkey,
staleTime: 30000, staleTime: STALE_TIME,
refetchOnMount: 'always', gcTime: GC_TIME,
refetchOnMount: true,
}); });
} }

View file

@ -1,8 +1,10 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect, lazy, Suspense } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { SpotMap } from '@/components/SpotMap';
// Lazy-load the map — Leaflet is large (~150KB) and only needed on this page
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { WeatherToggle } from '@/components/WeatherToggle'; import { WeatherToggle } from '@/components/WeatherToggle';
import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
@ -1061,25 +1063,34 @@ export default function MapPage() {
<div className="flex-1 relative"> <div className="flex-1 relative">
{viewMode === 'map' ? ( {viewMode === 'map' ? (
<> <>
<SpotMap <Suspense fallback={
spots={filteredSpots} <div className="h-full bg-black/50 flex items-center justify-center">
marketplaceItems={marketplaceItems || []} <div className="flex items-center gap-3">
tours={tours || []} <div className="w-6 h-6 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" />
btcMapPlaces={btcMapPlaces || []} <span className="text-sm text-white font-bold">Loading map...</span>
selectedSpot={selectedSpot} </div>
onSpotClick={setSelectedSpot} </div>
onMapClick={handleMapClick} }>
onMapMove={setMapCenter} <SpotMap
enableClickToAdd={!!user} spots={filteredSpots}
className="h-full" marketplaceItems={marketplaceItems || []}
weatherEnabled={weatherEnabled} tours={tours || []}
weatherData={weatherData} btcMapPlaces={btcMapPlaces || []}
showSpots={showSpots} selectedSpot={selectedSpot}
showShops={showShops} onSpotClick={setSelectedSpot}
showGear={showGear} onMapClick={handleMapClick}
showTours={showTours} onMapMove={setMapCenter}
showBtcMap={showBtcMap} enableClickToAdd={!!user}
/> className="h-full"
weatherEnabled={weatherEnabled}
weatherData={weatherData}
showSpots={showSpots}
showShops={showShops}
showGear={showGear}
showTours={showTours}
showBtcMap={showBtcMap}
/>
</Suspense>
{/* Loading indicator */} {/* Loading indicator */}
{isLoading && ( {isLoading && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-black/90 backdrop-blur px-4 py-2 rounded-lg shadow-lg border border-[#ff0040]/30"> <div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-black/90 backdrop-blur px-4 py-2 rounded-lg shadow-lg border border-[#ff0040]/30">

View file

@ -20,7 +20,7 @@ export default defineConfig(() => ({
return !log.includes("React Router Future Flag Warning"); return !log.includes("React Router Future Flag Warning");
}, },
env: { env: {
DEBUG_PRINT_LIMIT: '0', // Suppress DOM output that exceeds AI context windows DEBUG_PRINT_LIMIT: '0',
}, },
}, },
resolve: { resolve: {
@ -32,8 +32,55 @@ export default defineConfig(() => ({
exclude: ['@lightninglabs/lnc-web'], exclude: ['@lightninglabs/lnc-web'],
}, },
build: { build: {
// Strip console.log/warn/debug in production (keeps console.error for real errors)
minify: 'esbuild',
commonjsOptions: { commonjsOptions: {
transformMixedEsModules: true, transformMixedEsModules: true,
}, },
rollupOptions: {
output: {
// Split large vendor libraries into separate cacheable chunks.
// Users only re-download a chunk when that specific library updates.
manualChunks(id) {
// Map — Leaflet is large (~150KB), isolate it so non-map pages don't pay for it
if (id.includes('leaflet') || id.includes('ngeohash')) {
return 'vendor-map';
}
// Nostr core — changes infrequently, worth caching separately
if (id.includes('nostr-tools') || id.includes('@nostrify') || id.includes('@noble')) {
return 'vendor-nostr';
}
// Bitcoin / Lightning — large, rarely changes
if (
id.includes('@cashu') ||
id.includes('bitcoinjs-lib') ||
id.includes('@scure') ||
id.includes('@bitcoinerlab') ||
id.includes('ecpair') ||
id.includes('@getalby') ||
id.includes('npubcash')
) {
return 'vendor-bitcoin';
}
// React ecosystem — very stable, long cache life
if (id.includes('react-dom') || id.includes('react-router')) {
return 'vendor-react';
}
// Radix UI components — large but stable
if (id.includes('@radix-ui')) {
return 'vendor-radix';
}
// TanStack Query
if (id.includes('@tanstack')) {
return 'vendor-query';
}
},
},
},
}, },
})); // Strip console.log/warn/debug calls from production build
esbuild: {
drop: ['debugger'],
pure: ['console.log', 'console.warn', 'console.debug'],
},
}));