perf: batch profiles, gate map layers, warm public cache
This commit is contained in:
parent
cdeedefb74
commit
e94c16a479
9 changed files with 183 additions and 90 deletions
13
AGENTS.md
13
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=…`.
|
||||
|
|
|
|||
|
|
@ -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<string, PendingAuthor>();
|
||||
const inflight = new Map<string, Promise<AuthorResult>>();
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flush();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function loadAuthor(nostr: NPool, pubkey: string): Promise<AuthorResult> {
|
||||
const existing = inflight.get(pubkey);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = new Promise<AuthorResult>((resolve, reject) => {
|
||||
pending.set(pubkey, { nostr, resolve, reject });
|
||||
scheduleFlush();
|
||||
});
|
||||
inflight.set(pubkey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchProfiles(
|
||||
nostr: NPool,
|
||||
pubkeys: string[],
|
||||
): Promise<Map<string, NostrEvent>> {
|
||||
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<string, NostrEvent>();
|
||||
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<void> {
|
||||
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<NPool, Array<[string, PendingAuthor]>>();
|
||||
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<AuthorResult>({
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -58,14 +58,23 @@ const PERSIST_ALLOW_LIST = new Set<string>([
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -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<ViewMode>('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
|
||||
|
|
|
|||
Loading…
Reference in a new issue