fix: early sentinel trigger + short author prefetch timeout

- Feed sentinel now fires 400px before the bottom (rootMargin: '400px')
  so the next page fetch starts while the user is still reading, hiding
  the latency entirely in most cases

- Author prefetch timeout cut from 5000ms to 1500ms — relay responds
  well within that window for cached/known profiles; cards whose authors
  miss the deadline fall back to individual useAuthor queries rather than
  blocking the whole page render

- Stats prefetch reverted to fire-and-forget — not worth blocking on,
  useEventStats per-card handles misses with its own skeleton state

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 10:38:50 -06:00
parent e1e84d7d71
commit 97520999c0
2 changed files with 30 additions and 28 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export function Feed() {
await queryClient.invalidateQueries({ queryKey: ['feed', activeTab] });
}, [queryClient, activeTab]);
const { ref, inView } = useInView();
const { ref, inView } = useInView({ rootMargin: '400px' });
useEffect(() => {
if (inView && hasNextPage && !isFetching) {
+29 -27
View File
@@ -71,34 +71,36 @@ async function prefetchPageData(
.map((item) => item.event.id)
.filter((id) => queryClient.getQueryData(['event-stats', id]) === undefined);
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(),
// Await author prefetch with a short deadline — whatever profiles arrive
// in time get cached; cards whose authors miss the window fall back to
// individual useAuthor queries (acceptable for a minority of cards).
if (pubkeysToFetch.length > 0) {
await nostr.query(
[{ kinds: [0], authors: pubkeysToFetch }],
{ signal: AbortSignal.timeout(1500) },
).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(() => {});
}
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(),
]);
// Stats are fire-and-forget — nice to have pre-cached but not worth blocking on.
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(() => {});
}
}
/** Hook to fetch the global or followed feed with infinite scroll pagination. */