fix: parallelize author/stats prefetch and stop seeding empty author entries

Two bugs in the useFeed prefetch approach:

1. Empty author seeding was blocking mentions from resolving.
   We were calling setQueryData(['author', pk], {}) for any feed post
   author with no profile on our relay. If that same pubkey appeared
   as a @mention inside another post, useAuthor found the empty {} in
   cache, considered it fresh, and never queried — so the mention showed
   a generated name forever. Fix: only seed cache when we actually got a
   profile event back. Let useAuthor handle the no-profile case itself.

2. Author and stats prefetches were sequential (await author, then await
   stats) when they're completely independent. Now run in parallel with
   Promise.all — page load time roughly halved for the prefetch step.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 10:03:03 -06:00
parent 1dfe209690
commit 3e3d7e3dcf
+39 -46
View File
@@ -152,61 +152,54 @@ export function useFeed(tab: 'follows' | 'global') {
.map((ev) => ({ event: ev, sortTimestamp: ev.created_at }));
}
// --- Pre-populate author and stats caches ---
// By seeding the cache here, inside the queryFn, we guarantee that
// ['author', pubkey] and ['event-stats', id] are populated *before*
// React re-renders and NoteCard's individual hooks run. This eliminates
// the race where NoteCards mount and fire per-card queries before the
// batch results arrive.
// --- Pre-populate author and stats caches in parallel ---
// Seeding here, inside the queryFn, guarantees caches are populated
// before React re-renders and NoteCard's individual hooks run —
// eliminating the race where per-card queries fire before batch results arrive.
// We only fetch what isn't already cached, and we never seed empty entries
// so that mentions / quoted-note authors can still resolve via useAuthor.
// Authors — only fetch pubkeys not already cached
const pubkeysToFetch = [...new Set(
items.flatMap((item) => item.repostedBy ? [item.event.pubkey, item.repostedBy] : [item.event.pubkey]),
)].filter((pk) => queryClient.getQueryData(['author', pk]) === undefined);
if (pubkeysToFetch.length > 0) {
try {
const profileEvents = await nostr.query(
[{ kinds: [0], authors: pubkeysToFetch, limit: pubkeysToFetch.length }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
for (const ev of profileEvents) {
let metadata: NostrMetadata | undefined;
try { metadata = n.json().pipe(n.metadata()).parse(ev.content); } catch { /* skip */ }
queryClient.setQueryData(['author', ev.pubkey], { event: ev, metadata });
}
// Seed empty entry for any pubkey that returned no profile
for (const pk of pubkeysToFetch) {
if (queryClient.getQueryData(['author', pk]) === undefined) {
queryClient.setQueryData(['author', pk], {});
}
}
} catch {
// Timeout or abort — NoteCard's individual useAuthor will handle it
}
}
// Stats — only fetch event IDs not already cached
const eventIdsToFetch = items
.map((item) => item.event.id)
.filter((id) => queryClient.getQueryData(['event-stats', id]) === undefined);
if (eventIdsToFetch.length > 0) {
try {
const statEvents = await nostr.query(
[
{ kinds: [1, 6, 7, 9735], '#e': eventIdsToFetch, limit: eventIdsToFetch.length * 10 },
{ kinds: [1], '#q': eventIdsToFetch, limit: eventIdsToFetch.length * 3 },
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
);
for (const id of eventIdsToFetch) {
queryClient.setQueryData(['event-stats', id], computePageStats(id, statEvents));
}
} catch {
// Timeout or abort — NoteCard's individual useEventStats will handle it
}
}
await Promise.all([
// Fetch author profiles
pubkeysToFetch.length > 0
? nostr.query(
[{ kinds: [0], authors: pubkeysToFetch, limit: pubkeysToFetch.length }],
{ signal: AbortSignal.any([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 */ }
queryClient.setQueryData(['author', ev.pubkey], { event: ev, metadata });
}
// Note: we intentionally do NOT seed {} for pubkeys with no profile.
// Doing so would block useAuthor from querying for mentioned users
// whose pubkeys weren't in the feed events themselves.
}).catch(() => { /* timeout — per-card useAuthor will handle it */ })
: Promise.resolve(),
// Fetch interaction stats
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.any([signal, AbortSignal.timeout(6000)]) },
).then((statEvents) => {
for (const id of eventIdsToFetch) {
queryClient.setQueryData(['event-stats', id], computePageStats(id, statEvents));
}
}).catch(() => { /* timeout — per-card useEventStats will handle it */ })
: Promise.resolve(),
]);
return items;
},