perf: batch profiles, gate map layers, warm public cache

This commit is contained in:
SkateSpots 2026-10-28 12:42:39 +07:00
parent cdeedefb74
commit e94c16a479
9 changed files with 183 additions and 90 deletions

View file

@ -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=…`.

View file

@ -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),
});
}

View file

@ -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),

View file

@ -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),

View file

@ -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);

View file

@ -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);

View file

@ -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),

View file

@ -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',

View file

@ -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