From 409d443dbda5124f05972e043c91eaf46bed2ccd Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 18 Feb 2026 00:49:13 -0600 Subject: [PATCH] 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 --- src/hooks/useEvent.ts | 43 +++++++++++++++++++++--------------- src/hooks/useFeed.ts | 22 +++++++++++++++--- src/pages/PostDetailPage.tsx | 10 +++++---- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts index 32e79484..0b21c677 100644 --- a/src/hooks/useEvent.ts +++ b/src/hooks/useEvent.ts @@ -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), }); } diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 1c6eca35..f2442f50 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -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) => { diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 1462ff24..2d2ca049 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -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 ( @@ -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 (