From e1e84d7d71f4ca4cbb0ede2d8bfc9288be5d5394 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 18 Feb 2026 10:32:45 -0600 Subject: [PATCH] fix: await prefetch in queryFn so cache is populated before NoteCards mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire-and-forget prefetch had a fundamental race: React renders all NoteCards immediately when the feed query resolves, before the prefetch has a chance to populate the cache. Each card's useAuthor call finds an empty cache and opens its own relay subscription — 20+ concurrent REQ messages per relay, which hits subscription limits and most fail silently. The only reliable fix is to await the prefetch inside the queryFn before returning items. This ensures ['author', pubkey] is in cache for every pubkey on the page when React renders the cards. useAuthor finds fresh data and never opens a relay subscription. Authors and stats are fetched in parallel (Promise.all), so the extra latency is max(author_time, stats_time) not their sum. With eoseTimeout at 500ms this adds ~500ms per page — a worthwhile tradeoff for reliable author resolution vs the broken fire-and-forget approach. Co-authored-by: shakespeare.diy --- src/hooks/useFeed.ts | 64 ++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 1d853269..7e628062 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -49,12 +49,11 @@ function parseRepostContent(repost: NostrEvent): NostrEvent | undefined { * Includes post authors, repost authors, and mentioned p-tag pubkeys so that * @mentions and reply-to lines also resolve without individual round trips. */ -function prefetchPageData( +async function prefetchPageData( items: FeedItem[], nostr: ReturnType['nostr'], queryClient: ReturnType, ) { - // Collect every pubkey we'll need: authors, reposters, and p-tag mentions const allPubkeys = new Set(); for (const item of items) { allPubkeys.add(item.event.pubkey); @@ -72,34 +71,34 @@ function prefetchPageData( .map((item) => item.event.id) .filter((id) => queryClient.getQueryData(['event-stats', id]) === undefined); - // Profiles - if (pubkeysToFetch.length > 0) { - nostr.query( - [{ kinds: [0], authors: pubkeysToFetch }], - { signal: AbortSignal.timeout(5000) }, - ).then((profileEvents) => { - for (const ev of profileEvents) { - let metadata: NostrMetadata | undefined; - try { metadata = n.json().pipe(n.metadata()).parse(ev.content); } catch { /* skip */ } - seedAuthorCache(queryClient, ev.pubkey, { event: ev, metadata }); - } - }).catch(() => { /* relay timeout — useAuthor per-card will handle it */ }); - } + await Promise.all([ + pubkeysToFetch.length > 0 + ? nostr.query( + [{ kinds: [0], authors: pubkeysToFetch }], + { signal: AbortSignal.timeout(3000) }, + ).then((profileEvents) => { + for (const ev of profileEvents) { + let metadata: NostrMetadata | undefined; + try { metadata = n.json().pipe(n.metadata()).parse(ev.content); } catch { /* skip */ } + seedAuthorCache(queryClient, ev.pubkey, { event: ev, metadata }); + } + }).catch(() => {}) + : Promise.resolve(), - // Stats - if (eventIdsToFetch.length > 0) { - nostr.query( - [ - { kinds: [1, 6, 7, 9735], '#e': eventIdsToFetch, limit: eventIdsToFetch.length * 10 }, - { kinds: [1], '#q': eventIdsToFetch, limit: eventIdsToFetch.length * 3 }, - ], - { signal: AbortSignal.timeout(6000) }, - ).then((statEvents) => { - for (const id of eventIdsToFetch) { - queryClient.setQueryData(['event-stats', id], computePageStats(id, statEvents)); - } - }).catch(() => { /* relay timeout — useEventStats per-card will handle it */ }); - } + eventIdsToFetch.length > 0 + ? nostr.query( + [ + { kinds: [1, 6, 7, 9735], '#e': eventIdsToFetch, limit: eventIdsToFetch.length * 10 }, + { kinds: [1], '#q': eventIdsToFetch, limit: eventIdsToFetch.length * 3 }, + ], + { signal: AbortSignal.timeout(6000) }, + ).then((statEvents) => { + for (const id of eventIdsToFetch) { + queryClient.setQueryData(['event-stats', id], computePageStats(id, statEvents)); + } + }).catch(() => {}) + : Promise.resolve(), + ]); } /** Hook to fetch the global or followed feed with infinite scroll pagination. */ @@ -202,9 +201,10 @@ export function useFeed(tab: 'follows' | 'global') { .map((ev) => ({ event: ev, sortTimestamp: ev.created_at })); } - // Kick off author + stats prefetch in the background. - // Does NOT block this queryFn — items render immediately. - prefetchPageData(items, nostr, queryClient); + // Prefetch authors and stats in parallel before returning. + // This ensures cache is populated before NoteCards mount, so individual + // useAuthor/useEventStats calls hit cache and never open relay subscriptions. + await prefetchPageData(items, nostr, queryClient); return items; },