From e94c16a4796a223cc8bc4db8ae1d364eff955a3b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Wed, 28 Oct 2026 12:42:39 +0700 Subject: [PATCH] perf: batch profiles, gate map layers, warm public cache --- AGENTS.md | 13 +++ src/hooks/useAuthor.ts | 192 ++++++++++++++++++++++++------------ src/hooks/useCrews.ts | 5 +- src/hooks/useMarketplace.ts | 5 +- src/hooks/useShops.ts | 4 +- src/hooks/useSkateEvents.ts | 4 +- src/hooks/useTours.ts | 5 +- src/lib/queryPersister.ts | 9 ++ src/pages/MapPage.tsx | 36 +++---- 9 files changed, 183 insertions(+), 90 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27c54af..64290eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2247,6 +2247,19 @@ Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngi `coco-cashu`), so don't chase those. The 1.9 MB entry is mostly framework + the English dictionary + shared UI; shrinking it means trimming those, not re-adding `manualChunks` (see the TDZ warning above). +- **Load/relay performance (2026-10-28):** three structural costs were removed. + ① `useAuthor` is now **batched** (module queue + one flush per tick): profiles + are fetched with **one** main/fallback/community query per 100 authors instead + of 3 queries per card; React Query still owns per-author cache keys. ② + `MapPage` only queries layers that are actually on — Events/Crews default off + and no longer fetch at startup; Shops/Gear/Tours pass `enabled` too. ③ + `queryPersister`'s allow-list now matches the real keys (`skate-crews`, + `skate-tour`/`skate-tours`, `shop-directory`, `newsletter-issues`, …), so + cold starts rehydrate those datasets. Next targets: the notification bell's + 5+ source queries on every cold start (split trigger/panel or one summary + query), paginate the map's 2000-spot payload, a no-vendor service worker for + hashed assets/app shell, and the translation model (617 MB M2M100 is the + biggest latency cost; use a smaller model or native ONNX). ### ⚠️ "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=…`. diff --git a/src/hooks/useAuthor.ts b/src/hooks/useAuthor.ts index c5545eb..f5dde29 100644 --- a/src/hooks/useAuthor.ts +++ b/src/hooks/useAuthor.ts @@ -1,4 +1,4 @@ -import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nostrify'; +import { type NostrEvent, type NostrMetadata, NSchema as n, type NPool } from '@nostrify/nostrify'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import { SKATE_RELAY } from '@/lib/types'; @@ -11,73 +11,137 @@ const FALLBACK_RELAYS = [ 'wss://relay.damus.io', ]; +interface AuthorResult { + event?: NostrEvent; + metadata?: NostrMetadata; +} + +interface PendingAuthor { + nostr: NPool; + resolve: (value: AuthorResult) => void; + reject: (reason: unknown) => void; +} + +/** + * Profile requests are batched. A page with 40 cards used to fire 40 × 3 + * queries (main pool + fallback indexers + community relay); now React Query's + * per-author query functions join one batch per tick, and the batch does three + * relay queries total per 100 authors. This is the single biggest relay-load + * win on map/list/crew surfaces. + */ +const pending = new Map(); +const inflight = new Map>(); +let flushTimer: ReturnType | null = null; + +function scheduleFlush(): void { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flush(); + }, 0); +} + +function loadAuthor(nostr: NPool, pubkey: string): Promise { + const existing = inflight.get(pubkey); + if (existing) return existing; + + const promise = new Promise((resolve, reject) => { + pending.set(pubkey, { nostr, resolve, reject }); + scheduleFlush(); + }); + inflight.set(pubkey, promise); + return promise; +} + +async function fetchProfiles( + nostr: NPool, + pubkeys: string[], +): Promise> { + const main = nostr.query( + [{ kinds: [0], authors: pubkeys, limit: pubkeys.length }], + { signal: AbortSignal.timeout(12_000) }, + ); + const fallback = nostr + .group(FALLBACK_RELAYS) + .query([{ kinds: [0], authors: pubkeys, limit: pubkeys.length }], { + signal: AbortSignal.timeout(8_000), + }); + const community = nostr + .relay(SKATE_RELAY) + .query([{ kinds: [0], authors: pubkeys, limit: pubkeys.length }], { + signal: AbortSignal.timeout(8_000), + }); + + const results = await Promise.allSettled([main, fallback, community]); + const events = results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])); + if (results.every((result) => result.status === 'rejected')) { + throw new Error('profile relays unreachable'); + } + + const latest = new Map(); + for (const event of events) { + if (event.kind !== 0 || !pubkeys.includes(event.pubkey)) continue; + const current = latest.get(event.pubkey); + if (!current || event.created_at > current.created_at) latest.set(event.pubkey, event); + } + return latest; +} + +async function flush(): Promise { + if (pending.size === 0) return; + const entries = [...pending.entries()]; + pending.clear(); + + // A page can mix pools (rare, e.g. a portal/test), so group by identity. + const byNostr = new Map>(); + for (const entry of entries) { + const list = byNostr.get(entry[1].nostr) ?? []; + list.push(entry); + byNostr.set(entry[1].nostr, list); + } + + for (const [nostr, list] of byNostr) { + for (let i = 0; i < list.length; i += 100) { + const chunk = list.slice(i, i + 100); + const pubkeys = chunk.map(([pubkey]) => pubkey); + try { + const latest = await fetchProfiles(nostr, pubkeys); + for (const [pubkey, request] of chunk) { + const event = latest.get(pubkey); + if (!event) { + request.resolve({}); + continue; + } + try { + const metadata = n.json().pipe(n.metadata()).parse(event.content); + request.resolve({ metadata, event }); + } catch { + request.resolve({ event }); + } + } + } catch (error) { + for (const [, request] of chunk) request.reject(error); + } finally { + for (const [pubkey] of chunk) inflight.delete(pubkey); + } + } + } +} + +/** Batch-friendly profile lookup. Query keys are unchanged for compatibility. */ export function useAuthor(pubkey: string | undefined) { const { nostr } = useNostr(); - return useQuery<{ event?: NostrEvent; metadata?: NostrMetadata }>({ + return useQuery({ queryKey: ['author', pubkey ?? ''], - queryFn: async ({ signal }) => { - if (!pubkey) { - return {}; - } - - const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]); - - try { - // Query the main pool, the global aggregators, AND the app's own - // community relay — each INDEPENDENTLY. They must not be in one group: - // a single flaky relay there would reject the whole group and drop a - // profile that only lives on the community relay (the "generated name / - // missing avatar" bug). - const results = await Promise.allSettled([ - nostr.query( - [{ kinds: [0], authors: [pubkey], limit: 1 }], - { signal: querySignal }, - ), - (async () => { - const fallbackGroup = nostr.group(FALLBACK_RELAYS); - return fallbackGroup.query( - [{ kinds: [0], authors: [pubkey], limit: 1 }], - { signal: AbortSignal.timeout(8000) }, - ); - })(), - nostr.relay(SKATE_RELAY).query( - [{ kinds: [0], authors: [pubkey], limit: 1 }], - { signal: AbortSignal.timeout(8000) }, - ), - ]); - - // Collect every kind 0 event we found and pick the most recent one. - const candidates: NostrEvent[] = []; - for (const r of results) { - if (r.status === 'fulfilled' && Array.isArray(r.value)) { - candidates.push(...r.value); - } - } - - const event = candidates - .filter((e) => e.kind === 0 && e.pubkey === pubkey) - .sort((a, b) => b.created_at - a.created_at)[0]; - - if (!event) { - // No profile found - this is okay, user may not have published one yet - return {}; - } - - try { - const metadata = n.json().pipe(n.metadata()).parse(event.content); - return { metadata, event }; - } catch { - return { event }; - } - } catch (error) { - // Network error or timeout - return empty to avoid breaking the UI - console.warn('[useAuthor] Failed to fetch profile for', pubkey?.slice(0, 8), error); - return {}; - } + enabled: !!pubkey, + queryFn: async () => { + if (!pubkey) return {}; + return loadAuthor(nostr, pubkey); }, - staleTime: 60 * 1000, // 1 minute — allows NostrSync to invalidate and refetch quickly - retry: 5, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000), }); } diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index 5589c62..d5c7153 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -10,13 +10,14 @@ import { import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; -export function useCrews(limit = 200, options: { includeHidden?: boolean } = {}) { - const { includeHidden = false } = options; +export function useCrews(limit = 200, options: { includeHidden?: boolean; enabled?: boolean } = {}) { + const { includeHidden = false, enabled = true } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crews', limit, getQueryKeySuffix()], + enabled, // `includeHidden`: admin tooling only (review queue + undo). select: (crews: SkateCrew[]) => includeHidden ? crews : filterAdminHidden(crews, SKATE_CREW_KIND, hidden), diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index 5325b20..0b49a80 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -7,13 +7,14 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min const GC_TIME = 30 * 60 * 1000; // 30 min const TIMEOUT = 8000; -export function useMarketplaceItems(limit = 100, options: { includeHidden?: boolean } = {}) { - const { includeHidden = false } = options; +export function useMarketplaceItems(limit = 100, options: { includeHidden?: boolean; enabled?: boolean } = {}) { + const { includeHidden = false, enabled = true } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['marketplace-items', limit, getQueryKeySuffix()], + enabled, // `includeHidden`: admin tooling only (review queue + undo). select: (items: MarketplaceItem[]) => includeHidden ? items : filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden), diff --git a/src/hooks/useShops.ts b/src/hooks/useShops.ts index 32ff385..b8cf7c2 100644 --- a/src/hooks/useShops.ts +++ b/src/hooks/useShops.ts @@ -2,12 +2,14 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_SHOP_KIND, SKATE_SPOT_KIND, parseSkateShop, parseSkateSpot, type SkateShop } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; -export function useShops(limit = 100) { +export function useShops(limit = 100, options: { enabled?: boolean } = {}) { + const { enabled = true } = options; // Map/shop directory is canonical: shops come from the community relay only. const { queryCanonical } = useSkateRelays(); return useQuery({ queryKey: ['shop-directory', 'list', limit, 'canonical'], + enabled, queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts index 931ebe1..d3c1436 100644 --- a/src/hooks/useSkateEvents.ts +++ b/src/hooks/useSkateEvents.ts @@ -52,13 +52,15 @@ export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } }); } -export function useUpcomingSkateEvents(limit = 20) { +export function useUpcomingSkateEvents(limit = 20, options: { enabled?: boolean } = {}) { + const { enabled = true } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); const now = Math.floor(Date.now() / 1000); return useQuery({ queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()], + enabled, select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index 69cbf20..5a4510d 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -8,13 +8,14 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min const GC_TIME = 30 * 60 * 1000; // 30 min const TIMEOUT = 8000; -export function useTours(limit = 100, options: { includeHidden?: boolean } = {}) { - const { includeHidden = false } = options; +export function useTours(limit = 100, options: { includeHidden?: boolean; enabled?: boolean } = {}) { + const { includeHidden = false, enabled = true } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-tours', limit, getQueryKeySuffix()], + enabled, // `includeHidden`: admin tooling only (review queue + undo). select: (tours: SkateTour[]) => includeHidden ? tours : filterAdminHidden(tours, SKATE_TOUR_KIND, hidden), diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts index 98a5268..35ec48d 100644 --- a/src/lib/queryPersister.ts +++ b/src/lib/queryPersister.ts @@ -58,14 +58,23 @@ const PERSIST_ALLOW_LIST = new Set([ 'skate-shops', 'shops', 'skate-events', + 'skate-event', 'upcoming-skate-events', 'tours', + 'skate-tours', + 'skate-tour', + 'user-skate-tours', 'crews', + 'skate-crews', + 'skate-crew', + 'skate-crews-focus', + 'shop-directory', 'coaches', 'lab-profiles', 'marketplace', 'marketplace-items', 'skaters', + 'newsletter-issues', // Author profiles (kind 0) — avatars/names everywhere benefit hugely 'author', 'authors', diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 94e868d..0ee9297 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -546,6 +546,17 @@ export default function MapPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + // ?view=gear focuses the gear layer; compute before the data hooks so hidden + // layers don't query at all. + const viewParam = searchParams.get('view'); + const focusGear = viewParam === 'gear'; + const [showSpots, setShowSpots] = useState(!focusGear); + const [showShops, setShowShops] = useState(!focusGear); + const [showGear, setShowGear] = useState(true); + const [showTours, setShowTours] = useState(!focusGear); + // Opt-in layers — no relay query until the user turns them on. + const [showEvents, setShowEvents] = useState(false); + const [showCrews, setShowCrews] = useState(false); const { user } = useCurrentUser(); // The user's own profile picture, shown on the "you are here" pin. const meAuthor = useAuthor(user?.pubkey || ''); @@ -553,7 +564,7 @@ export default function MapPage() { const { data: followingList } = useFollowing(user?.pubkey); const followCount = followingList?.length ?? 0; const { data: spots, isLoading: spotsLoading, refetch: refetchSpots } = useSkateSpots(2000); - const { data: shops, isLoading: shopsLoading } = useShops(500); + const { data: shops, isLoading: shopsLoading } = useShops(500, { enabled: showShops }); // Wi-Fi present ≠ relay reachable (captive portal / relay outage). The utility // prefers saved spots over a blank map, so we detect that honestly and say so. @@ -595,16 +606,12 @@ export default function MapPage() { window.dispatchEvent(new Event('skatespots:content-ready')); } }, [spots, spotsLoading]); - const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200); - const { data: tours, isLoading: toursLoading } = useTours(100); + const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200, { enabled: showGear }); + const { data: tours, isLoading: toursLoading } = useTours(100, { enabled: showTours }); // Upcoming events for the (opt-in) Events map layer - const { data: upcomingEvents } = useUpcomingSkateEvents(100); + const { data: upcomingEvents } = useUpcomingSkateEvents(100, { enabled: showEvents }); // Crews for the (opt-in) Crews map layer - const { data: crews } = useCrews(200); - - // Check if coming with ?view=gear to focus on marketplace - const viewParam = searchParams.get('view'); - const focusGear = viewParam === 'gear'; + const { data: crews } = useCrews(200, { enabled: showCrews }); const [viewMode, setViewMode] = useState('map'); const [searchQuery, setSearchQuery] = useState(''); @@ -638,15 +645,8 @@ export default function MapPage() { return cached ? { lat: cached.lat, lon: cached.lng } : null; }); - // Map entity type filters - if ?view=gear, only show gear initially - const [showSpots, setShowSpots] = useState(!focusGear); - const [showShops, setShowShops] = useState(!focusGear); - const [showGear, setShowGear] = useState(true); - const [showTours, setShowTours] = useState(!focusGear); - // Events layer — off by default; users opt in via the Layers control - const [showEvents, setShowEvents] = useState(false); - // Crews layer — off by default; users opt in via the Layers control - const [showCrews, setShowCrews] = useState(false); + // Map entity type filters (state lives above the queries so hidden layers + // don't open relay requests). const [showBtcMap, setShowBtcMap] = useState(false); // Mobile filters sheet