From 3e3d7e3dcfecfcdf9e3f378a67fa2291dbe33089 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 18 Feb 2026 10:03:03 -0600 Subject: [PATCH] fix: parallelize author/stats prefetch and stop seeding empty author entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useFeed.ts | 85 ++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 339317d6..f2fae5a7 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -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; },