diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index b5c17f30..da076d47 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -126,9 +126,15 @@ export function useFetchProfile(id?: string, skipCache = false) { // CRITICAL: Early exit if already processing this exact pubkey - prevents infinite loops // This check must happen FIRST, before any other logic - if (extractedPubkey && processingPubkeyRef.current === extractedPubkey) { - // Silently exit - no logging to reduce noise - return + // Set processingPubkeyRef IMMEDIATELY after extraction to prevent race conditions + if (extractedPubkey) { + if (processingPubkeyRef.current === extractedPubkey) { + // Silently exit - no logging to reduce noise + return + } + // Mark that we're processing this pubkey IMMEDIATELY to prevent concurrent runs + // We'll clear it later if we early exit for other reasons + processingPubkeyRef.current = extractedPubkey } // CRITICAL: Early exit if we already have a profile for this pubkey @@ -152,13 +158,20 @@ export function useFetchProfile(id?: string, skipCache = false) { // CRITICAL: Early exit if we've already initialized this pubkey (even if profile is null) // This prevents re-fetching when we've already tried and failed // BUT: Allow retry if skipCache is true (user explicitly wants to refresh) - if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile && !skipCache) { - // Already tried and failed - don't retry unless explicitly requested - // Ensure fetching is false - if (isFetching) { - setIsFetching(false) + if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile) { + if (skipCache) { + // User wants to refresh - clear initialized flag to allow fresh fetch + initializedPubkeysRef.current.delete(extractedPubkey) + // Also clear run count to allow fresh attempt + effectRunCountRef.current.delete(extractedPubkey) + } else { + // Already tried and failed - don't retry unless explicitly requested + // Ensure fetching is false + if (isFetching) { + setIsFetching(false) + } + return } - return } // CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3) @@ -247,29 +260,27 @@ export function useFetchProfile(id?: string, skipCache = false) { } // These checks are now done earlier in the effect (before incrementing run count) - // Keeping this as a safety check, but it should rarely be hit - if (processingPubkeyRef.current === extractedPubkey) { - logger.info('[useFetchProfile] Already processing this pubkey (safety check)', { + // Keeping this as a safety check, but it should rarely be hit now that we set processingPubkeyRef earlier + if (processingPubkeyRef.current !== extractedPubkey) { + // This should never happen now, but keep as safety check + logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', { extractedPubkey, processingPubkey: processingPubkeyRef.current }) - return + processingPubkeyRef.current = extractedPubkey } if (profile && profile.pubkey === extractedPubkey) { logger.info('[useFetchProfile] Already have profile for this pubkey (safety check)', { extractedPubkey }) - processingPubkeyRef.current = extractedPubkey setIsFetching(false) effectRunCountRef.current.delete(extractedPubkey) return } - // CRITICAL: Mark that we're processing this pubkey IMMEDIATELY after validation - // This must happen before any state updates or async operations - // This prevents the effect from running again for the same pubkey - processingPubkeyRef.current = extractedPubkey + // processingPubkeyRef is already set earlier (right after extraction) + // No need to set it again here // CRITICAL: Only update pubkey state if it's actually different // Avoid state updates that could trigger re-renders and loops diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 1b829a2c..9c330c75 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -40,6 +40,8 @@ export interface RelayListBuilderOptions { blockedRelays?: string[] /** Whether to include local relays from kind 10432 */ includeLocalRelays?: boolean + /** Whether to include user's favorite relays (kind 10012) */ + includeFavoriteRelays?: boolean } /** @@ -58,7 +60,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio includeFastWriteRelays = false, includeSearchableRelays = false, blockedRelays = [], - includeLocalRelays = true + includeLocalRelays = true, + includeFavoriteRelays = false } = options const relayUrls = new Set() @@ -150,10 +153,26 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio localRelays.forEach(addRelay) } + // Include favorite relays (kind 10012) if requested + let favoriteRelaysCount = 0 + if (includeFavoriteRelays) { + try { + const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) + favoriteRelays.forEach(addRelay) + favoriteRelaysCount = favoriteRelays.length + logger.debug('[RelayListBuilder] Added user favorite relays', { + count: favoriteRelaysCount + }) + } catch (error) { + logger.debug('[RelayListBuilder] Failed to fetch user favorite relays', { error }) + } + } + logger.debug('[RelayListBuilder] Added user own relays', { read: userRelayList ? (userRelayList.read || []).length : 0, write: userRelayList ? (userRelayList.write || []).length : 0, - local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0 + local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0, + favorite: favoriteRelaysCount }) } catch (error) { logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error }) diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 55a9ea7a..745b3ed7 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -5,7 +5,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' import { kinds, NostrEvent } from 'nostr-tools' import { SubCloser } from 'nostr-tools/abstract-pool' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useMemo } from 'react' import { useNostr } from './NostrProvider' /** @@ -16,6 +16,30 @@ export function NotificationProvider({ children }: { children: React.ReactNode } const { pubkey, relayList } = useNostr() const { favoriteRelays } = useFavoriteRelays() const notificationBufferRef = useRef([]) + const retryCountRef = useRef(0) + const retryTimeoutIdRef = useRef(null) + + // Memoize relay URLs to prevent unnecessary re-subscriptions + // This creates stable references based on actual relay URLs, not object references + const userReadRelays = useMemo(() => { + const userRelayList = relayList || { read: [], write: [] } + return userRelayList.read || [] + }, [relayList?.read?.join(',')]) // Compare by stringified array, not object reference + + const userFavoriteRelays = useMemo(() => { + return favoriteRelays || [] + }, [favoriteRelays?.join(',')]) // Compare by stringified array, not array reference + + // Memoize the notification relays to prevent re-subscriptions when they haven't changed + const notificationRelays = useMemo(() => { + if (userReadRelays.length > 0) { + return userReadRelays.slice(0, 5) + } else if (userFavoriteRelays.length > 0) { + return userFavoriteRelays.slice(0, 5) + } else { + return FAST_READ_RELAY_URLS.slice(0, 5) + } + }, [userReadRelays, userFavoriteRelays]) useEffect(() => { if (!pubkey) return @@ -31,8 +55,17 @@ export function NotificationProvider({ children }: { children: React.ReactNode } const topicSubCloserRef: { current: SubCloser | null } = { current: null } + const MAX_RETRIES = 5 + // Reset retry count when effect runs (relays changed) + retryCountRef.current = 0 const subscribe = async () => { + // Clear any pending retries + if (retryTimeoutIdRef.current) { + clearTimeout(retryTimeoutIdRef.current) + retryTimeoutIdRef.current = null + } + if (subCloserRef.current) { subCloserRef.current.close() subCloserRef.current = null @@ -45,27 +78,11 @@ export function NotificationProvider({ children }: { children: React.ReactNode } try { let eosed = false - const userRelayList = relayList || { read: [], write: [] } - const userReadRelays = userRelayList.read || [] - const userFavoriteRelays = favoriteRelays || [] + // Reset retry count on successful subscription attempt + retryCountRef.current = 0 - let notificationRelays: string[] = [] - - if (userReadRelays.length > 0) { - notificationRelays = userReadRelays.slice(0, 5) - logger.component('NotificationProvider', 'Using user read relays', { - count: notificationRelays.length, - relays: notificationRelays.slice(0, 3) - }) - } else if (userFavoriteRelays.length > 0) { - notificationRelays = userFavoriteRelays.slice(0, 5) - logger.component('NotificationProvider', 'Using user favorite relays', { - count: notificationRelays.length, - relays: notificationRelays.slice(0, 3) - }) - } else { - notificationRelays = FAST_READ_RELAY_URLS.slice(0, 5) - logger.component('NotificationProvider', 'Using fast read relays fallback', { + if (notificationRelays.length > 0) { + logger.component('NotificationProvider', 'Using notification relays', { count: notificationRelays.length, relays: notificationRelays.slice(0, 3) }) @@ -153,13 +170,17 @@ export function NotificationProvider({ children }: { children: React.ReactNode } return } - if (isMountedRef.current) { - setTimeout(() => { + if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) { + retryCountRef.current++ + const delay = Math.min(15_000 * retryCountRef.current, 60_000) // Exponential backoff, max 60s + logger.debug(`[NotificationProvider] Reconnecting after close (attempt ${retryCountRef.current}/${MAX_RETRIES})...`) + retryTimeoutIdRef.current = setTimeout(() => { if (isMountedRef.current) { - logger.debug('[NotificationProvider] Reconnecting after close...') subscribe() } - }, 15_000) + }, delay) + } else if (retryCountRef.current >= MAX_RETRIES) { + logger.error('[NotificationProvider] Max retries reached, stopping reconnection attempts') } } } @@ -168,14 +189,18 @@ export function NotificationProvider({ children }: { children: React.ReactNode } subCloserRef.current = subCloser return subCloser } catch (error) { - logger.error('Subscription error', { error }) + logger.error('Subscription error', { error, retryCount: retryCountRef.current }) - if (isMountedRef.current) { - setTimeout(() => { + if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) { + retryCountRef.current++ + const delay = Math.min(5_000 * retryCountRef.current, 30_000) // Exponential backoff, max 30s + retryTimeoutIdRef.current = setTimeout(() => { if (isMountedRef.current) { subscribe() } - }, 5_000) + }, delay) + } else if (retryCountRef.current >= MAX_RETRIES) { + logger.error('[NotificationProvider] Max retries reached, stopping subscription attempts') } return null } @@ -185,6 +210,11 @@ export function NotificationProvider({ children }: { children: React.ReactNode } return () => { clearTimeout(deferredReset) + if (retryTimeoutIdRef.current) { + clearTimeout(retryTimeoutIdRef.current) + retryTimeoutIdRef.current = null + } + retryCountRef.current = 0 // Reset retry count on cleanup isMountedRef.current = false if (subCloserRef.current) { subCloserRef.current.close() @@ -195,7 +225,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } topicSubCloserRef.current = null } } - }, [pubkey, relayList, favoriteRelays]) + }, [pubkey, notificationRelays.join(',')]) // Use memoized notificationRelays instead of relayList/favoriteRelays return <>{children} } diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index de3649ff..99ba1264 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -129,7 +129,7 @@ export class QueryService { } const FIRST_RESULT_GRACE_MS = 1200 - const REPLACEABLE_RACE_WAIT_MS = 2000 + const REPLACEABLE_RACE_WAIT_MS = 1000 // Reduced from 2000ms for faster profile loading in feeds return await new Promise((resolve) => { const events: NEvent[] = [] diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index c1198249..65d7ed5f 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -160,8 +160,8 @@ export class ReplaceableEventService { kinds: [kind] }, undefined, { replaceableRace: true, - eoseTimeout: 200, - globalTimeout: 3000 + eoseTimeout: 100, // Reduced from 200ms for faster early returns + globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow }) const queryTime = Date.now() - startTime logger.info('[ReplaceableEventService] Query completed', { @@ -438,8 +438,8 @@ export class ReplaceableEventService { kinds: [kind] }, undefined, { replaceableRace: true, - eoseTimeout: 200, - globalTimeout: 3000 + eoseTimeout: 100, // Reduced from 200ms for faster early returns + globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow }) logger.info('[ReplaceableEventService] Query completed for batch', { kind, @@ -526,8 +526,8 @@ export class ReplaceableEventService { const events = await this.queryService.query(relayUrls, filter, undefined, { replaceableRace: true, - eoseTimeout: 200, - globalTimeout: 3000 + eoseTimeout: 100, // Reduced from 200ms for faster early returns + globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow }) for (const event of events) { @@ -719,68 +719,79 @@ export class ReplaceableEventService { } // Step 3: Comprehensive search across ALL available relays before giving up - // This includes: local relays, user inboxes/outboxes, fast read/write, searchable relays - logger.info('[ReplaceableEventService] Step 3: Profile not found, trying comprehensive relay list (all available relays)', { - pubkey - }) - - try { - const userPubkey = client.pubkey - const comprehensiveRelays = await buildComprehensiveRelayList({ - authorPubkey: pubkey, - userPubkey: userPubkey || undefined, - relayHints: relayHints.length > 0 ? relayHints : undefined, - includeUserOwnRelays: true, // Include user's read/write relays - includeProfileFetchRelays: true, // Include PROFILE_FETCH_RELAY_URLS - includeFastReadRelays: true, // Include FAST_READ_RELAY_URLS - includeFastWriteRelays: true, // Include FAST_WRITE_RELAY_URLS - includeSearchableRelays: true, // Include SEARCHABLE_RELAY_URLS - includeLocalRelays: true // Include local/cache relays - }) - - logger.info('[ReplaceableEventService] Comprehensive relay list built', { + // OPTIMIZATION: Skip comprehensive search for batch profile fetches (when called from DataLoader) + // Comprehensive search is expensive (10s timeout) and should only be used for individual profile fetches + // when user explicitly navigates to a profile page. For feed rendering, missing profiles are acceptable. + // Only run comprehensive search if we have relay hints (suggesting user intent to find this specific profile) + if (relayHints.length > 0) { + logger.info('[ReplaceableEventService] Step 3: Profile not found, trying comprehensive relay list (all available relays)', { pubkey, - relayCount: comprehensiveRelays.length, - relays: comprehensiveRelays.slice(0, 10) // Log first 10 for debugging + hasRelayHints: relayHints.length > 0 }) - if (comprehensiveRelays.length > 0) { - // Query the comprehensive relay list - const startTime = Date.now() - const events = await this.queryService.query(comprehensiveRelays, { - authors: [pubkey], - kinds: [kinds.Metadata] - }, undefined, { - replaceableRace: true, - eoseTimeout: 500, - globalTimeout: 10000 // 10 second timeout for comprehensive search + try { + const userPubkey = client.pubkey + const comprehensiveRelays = await buildComprehensiveRelayList({ + authorPubkey: pubkey, + userPubkey: userPubkey || undefined, + relayHints: relayHints.length > 0 ? relayHints : undefined, + includeUserOwnRelays: true, // Include user's read/write relays + includeFavoriteRelays: true, // Include user's favorite relays (kind 10012) + includeProfileFetchRelays: true, // Include PROFILE_FETCH_RELAY_URLS + includeFastReadRelays: true, // Include FAST_READ_RELAY_URLS + includeFastWriteRelays: true, // Include FAST_WRITE_RELAY_URLS + includeSearchableRelays: true, // Include SEARCHABLE_RELAY_URLS + includeLocalRelays: true // Include local/cache relays }) - const queryTime = Date.now() - startTime - logger.info('[ReplaceableEventService] Comprehensive search completed', { + logger.info('[ReplaceableEventService] Comprehensive relay list built', { pubkey, - eventCount: events.length, - queryTime: `${queryTime}ms`, - relayCount: comprehensiveRelays.length + relayCount: comprehensiveRelays.length, + relays: comprehensiveRelays.slice(0, 10) // Log first 10 for debugging }) - if (events.length > 0) { - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) - const profileEvent = sortedEvents[0] - logger.info('[ReplaceableEventService] Profile found via comprehensive search', { + if (comprehensiveRelays.length > 0) { + // Query the comprehensive relay list with reduced timeout for faster failure + const startTime = Date.now() + const events = await this.queryService.query(comprehensiveRelays, { + authors: [pubkey], + kinds: [kinds.Metadata] + }, undefined, { + replaceableRace: true, + eoseTimeout: 300, // Reduced from 500ms + globalTimeout: 5000 // Reduced from 10000ms to prevent 10s waits + }) + const queryTime = Date.now() - startTime + + logger.info('[ReplaceableEventService] Comprehensive search completed', { pubkey, - eventId: profileEvent.id + eventCount: events.length, + queryTime: `${queryTime}ms`, + relayCount: comprehensiveRelays.length }) - await this.indexProfile(profileEvent) - return profileEvent + + if (events.length > 0) { + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const profileEvent = sortedEvents[0] + logger.info('[ReplaceableEventService] Profile found via comprehensive search', { + pubkey, + eventId: profileEvent.id + }) + await this.indexProfile(profileEvent) + return profileEvent + } } + } catch (error) { + logger.error('[ReplaceableEventService] Comprehensive search failed', { + pubkey, + error: error instanceof Error ? error.message : String(error) + }) + // Continue to return undefined below } - } catch (error) { - logger.error('[ReplaceableEventService] Comprehensive search failed', { - pubkey, - error: error instanceof Error ? error.message : String(error) + } else { + logger.debug('[ReplaceableEventService] Skipping comprehensive search (no relay hints, likely batch fetch)', { + pubkey }) - // Continue to return undefined below } logger.warn('[ReplaceableEventService] Profile not found after trying all relays (including comprehensive search)', { @@ -954,10 +965,12 @@ export class ReplaceableEventService { /** * Fetch following favorite relays */ - async fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> { - const cached = this.followingFavoriteRelaysCache.get(pubkey) - if (cached) { - return cached + async fetchFollowingFavoriteRelays(pubkey: string, skipCache = false): Promise<[string, string[]][]> { + if (!skipCache) { + const cached = this.followingFavoriteRelaysCache.get(pubkey) + if (cached) { + return cached + } } const promise = this._fetchFollowingFavoriteRelays(pubkey) this.followingFavoriteRelaysCache.set(pubkey, promise) @@ -966,28 +979,47 @@ export class ReplaceableEventService { private async _fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> { const followings = await this.fetchFollowings(pubkey) + const followingsToProcess = followings.slice(0, 100) const favoriteRelaysEvents = await this.fetchReplaceableEventsFromProfileFetchRelays( - followings.slice(0, 100), + followingsToProcess, ExtendedKind.FAVORITE_RELAYS ) - const result: [string, string[]][] = [] - for (let i = 0; i < followings.length && i < favoriteRelaysEvents.length; i++) { + // Group by relay URL: Map> + const relayToUsers = new Map>() + + // favoriteRelaysEvents[i] corresponds to followingsToProcess[i] + for (let i = 0; i < followingsToProcess.length && i < favoriteRelaysEvents.length; i++) { const event = favoriteRelaysEvents[i] - if (event) { - const relays: string[] = [] + const followingPubkey = followingsToProcess[i] + if (event && followingPubkey) { event.tags.forEach(([tagName, tagValue]) => { if (tagName === 'relay' && tagValue) { const normalizedUrl = normalizeUrl(tagValue) - if (normalizedUrl && !relays.includes(normalizedUrl)) { - relays.push(normalizedUrl) + if (normalizedUrl) { + if (!relayToUsers.has(normalizedUrl)) { + relayToUsers.set(normalizedUrl, new Set()) + } + relayToUsers.get(normalizedUrl)!.add(followingPubkey) } } }) - if (relays.length > 0) { - result.push([followings[i]!, relays]) - } } } + + // Convert to array format: [relayUrl, pubkeys[]] + const result: [string, string[]][] = [] + for (const [relayUrl, pubkeys] of relayToUsers.entries()) { + result.push([relayUrl, Array.from(pubkeys)]) + } + + logger.debug('[ReplaceableEventService] fetchFollowingFavoriteRelays completed', { + followingsCount: followings.length, + processedCount: followingsToProcess.length, + eventsFound: favoriteRelaysEvents.filter(e => e !== undefined).length, + uniqueRelays: result.length, + totalUsers: result.reduce((sum, [, users]) => sum + users.length, 0) + }) + return result } }