Fix event detail 404 by retrying instead of caching empty results

The NPool's 500ms eoseTimeout causes event-by-ID queries to resolve
before slower relays respond. Previously, useEvent returned null on
the first miss and the page immediately showed a 404.

Now:
- useEvent/useAddrEvent throw on miss so TanStack Query retries
  (3 retries with exponential backoff: 2s, 4s, 8s)
- PostDetailPage shows the skeleton while retrying (checks isFetching)
- Only shows 404 after all retries are exhausted
- Feed cache seeding from previous commit still provides instant
  navigation for events already seen in the feed

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 00:49:13 -06:00
parent 7d2c788ab0
commit 409d443dbd
3 changed files with 50 additions and 25 deletions
+25 -18
View File
@@ -2,7 +2,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
/** Fetches a single Nostr event by its hex ID, optionally querying relay hints. */
/** Fetches a single Nostr event by its hex ID, with retries to handle slow relays. */
export function useEvent(eventId: string | undefined, relays?: string[]) {
const { nostr } = useNostr();
@@ -10,28 +10,33 @@ export function useEvent(eventId: string | undefined, relays?: string[]) {
queryKey: ['event', eventId ?? ''],
queryFn: async ({ signal }) => {
if (!eventId) return null;
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const filter = [{ ids: [eventId], limit: 1 }];
// Query the user's configured relays first
const events = await nostr.query(filter, { signal: querySignal });
// Query the user's configured relays
const events = await nostr.query(filter, {
signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
});
if (events.length > 0) return events[0];
// If not found and we have relay hints, try those relays directly
// Try relay hints if available
if (relays && relays.length > 0) {
try {
const hintSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const hintEvents = await nostr.group(relays).query(filter, { signal: hintSignal });
const hintEvents = await nostr.group(relays).query(filter, {
signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
});
if (hintEvents.length > 0) return hintEvents[0];
} catch {
// relay hint query failed — fall through
// relay hint query failed
}
}
return null;
// Throw so TanStack Query retries instead of caching null
throw new Error('Event not found');
},
enabled: !!eventId,
staleTime: 5 * 60 * 1000,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
});
}
@@ -42,7 +47,7 @@ export interface AddrCoords {
identifier: string;
}
/** Fetches a single addressable Nostr event by kind + pubkey + d-tag, optionally querying relay hints. */
/** Fetches a single addressable Nostr event by kind + pubkey + d-tag, with retries. */
export function useAddrEvent(addr: AddrCoords | undefined, relays?: string[]) {
const { nostr } = useNostr();
@@ -50,27 +55,29 @@ export function useAddrEvent(addr: AddrCoords | undefined, relays?: string[]) {
queryKey: ['addr-event', addr?.kind ?? 0, addr?.pubkey ?? '', addr?.identifier ?? ''],
queryFn: async ({ signal }) => {
if (!addr) return null;
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const filter = [{ kinds: [addr.kind], authors: [addr.pubkey], '#d': [addr.identifier], limit: 1 }];
// Query the user's configured relays first
const events = await nostr.query(filter, { signal: querySignal });
const events = await nostr.query(filter, {
signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
});
if (events.length > 0) return events[0];
// If not found and we have relay hints, try those relays directly
if (relays && relays.length > 0) {
try {
const hintSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const hintEvents = await nostr.group(relays).query(filter, { signal: hintSignal });
const hintEvents = await nostr.group(relays).query(filter, {
signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
});
if (hintEvents.length > 0) return hintEvents[0];
} catch {
// relay hint query failed — fall through
// relay hint query failed
}
}
return null;
throw new Error('Event not found');
},
enabled: !!addr,
staleTime: 5 * 60 * 1000,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
});
}
+19 -3
View File
@@ -1,5 +1,5 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useFeedSettings } from './useFeedSettings';
import { useFollowList } from './useFollowActions';
@@ -41,6 +41,7 @@ function parseRepostContent(repost: NostrEvent): NostrEvent | undefined {
/** Hook to fetch the global or followed feed with infinite scroll pagination. */
export function useFeed(tab: 'follows' | 'global') {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const followList = followData?.pubkeys;
@@ -130,7 +131,15 @@ export function useFeed(tab: 'follows' | 'global') {
}
}
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
const result = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
// Seed the per-event cache so detail pages resolve instantly,
// even for events embedded in reposts that can't be fetched by ID.
for (const item of result) {
queryClient.setQueryData(['event', item.event.id], item.event);
}
return result;
} else {
// Global feed — kind 1 notes + user-selected extra kinds
const globalKinds = [1, ...extraKinds];
@@ -144,10 +153,17 @@ export function useFeed(tab: 'follows' | 'global') {
{ signal: querySignal },
);
return events
const result = events
.filter((ev) => ev.created_at <= now)
.sort((a, b) => b.created_at - a.created_at)
.map((ev) => ({ event: ev, sortTimestamp: ev.created_at }));
// Seed the per-event cache so detail pages resolve instantly.
for (const item of result) {
queryClient.setQueryData(['event', item.event.id], item.event);
}
return result;
}
},
getNextPageParam: (lastPage) => {
+6 -4
View File
@@ -126,13 +126,14 @@ function getParentEventId(event: NostrEvent): string | undefined {
}
export function PostDetailPage({ eventId, relays }: PostDetailPageProps) {
const { data: event, isLoading, isError } = useEvent(eventId, relays);
const { data: event, isLoading, isError, isFetching } = useEvent(eventId, relays);
useSeoMeta({
title: event ? 'Post Details - Mew' : 'Loading... - Mew',
});
if (isLoading) {
// Show skeleton while loading or retrying
if (isLoading || (!event && isFetching)) {
return (
<MainLayout>
<PostDetailShell>
@@ -157,7 +158,7 @@ export function PostDetailPage({ eventId, relays }: PostDetailPageProps) {
/** Detail page for addressable events (naddr). Same layout as PostDetailPage. */
export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
const { data: event, isLoading, isError } = useAddrEvent(addr, relays);
const { data: event, isLoading, isError, isFetching } = useAddrEvent(addr, relays);
useSeoMeta({
title: event
@@ -165,7 +166,8 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
: 'Loading... - Mew',
});
if (isLoading) {
// Show skeleton while loading or retrying
if (isLoading || (!event && isFetching)) {
return (
<MainLayout>
<PostDetailShell>