fix: await prefetch in queryFn so cache is populated before NoteCards mount

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 10:32:45 -06:00
parent 974490f323
commit e1e84d7d71
+32 -32
View File
@@ -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<typeof import('@nostrify/react').useNostr>['nostr'],
queryClient: ReturnType<typeof import('@tanstack/react-query').useQueryClient>,
) {
// Collect every pubkey we'll need: authors, reposters, and p-tag mentions
const allPubkeys = new Set<string>();
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;
},