Browse Source

bug-fix

imwald
Silberengel 1 month ago
parent
commit
a925184e65
  1. 33
      src/hooks/useFetchProfile.tsx
  2. 23
      src/lib/relay-list-builder.ts
  3. 90
      src/providers/NotificationProvider.tsx
  4. 2
      src/services/client-query.service.ts
  5. 74
      src/services/client-replaceable-events.service.ts

33
src/hooks/useFetchProfile.tsx

@ -126,10 +126,16 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -126,10 +126,16 @@ 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) {
// 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
// This prevents re-fetching when we already have the profile
@ -152,7 +158,13 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -152,7 +158,13 @@ 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) {
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) {
@ -160,6 +172,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -160,6 +172,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
return
}
}
// CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3)
// Only increment if we're actually going to process (not early exiting)
@ -247,29 +260,27 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -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

23
src/lib/relay-list-builder.ts

@ -40,6 +40,8 @@ export interface RelayListBuilderOptions { @@ -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 @@ -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<string>()
@ -150,10 +153,26 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -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 })

90
src/providers/NotificationProvider.tsx

@ -5,7 +5,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -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 } @@ -16,6 +16,30 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const { pubkey, relayList } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
const notificationBufferRef = useRef<NostrEvent[]>([])
const retryCountRef = useRef(0)
const retryTimeoutIdRef = useRef<NodeJS.Timeout | null>(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 } @@ -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 } @@ -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 || []
let notificationRelays: string[] = []
// Reset retry count on successful subscription attempt
retryCountRef.current = 0
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 } @@ -153,13 +170,17 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
return
}
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) {
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 } @@ -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 } @@ -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 } @@ -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}</>
}

2
src/services/client-query.service.ts

@ -129,7 +129,7 @@ export class QueryService { @@ -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<NEvent[]>((resolve) => {
const events: NEvent[] = []

74
src/services/client-replaceable-events.service.ts

@ -160,8 +160,8 @@ export class ReplaceableEventService { @@ -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 { @@ -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 { @@ -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,9 +719,14 @@ export class ReplaceableEventService { @@ -719,9 +719,14 @@ 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
// 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
pubkey,
hasRelayHints: relayHints.length > 0
})
try {
@ -731,6 +736,7 @@ export class ReplaceableEventService { @@ -731,6 +736,7 @@ export class ReplaceableEventService {
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
@ -745,15 +751,15 @@ export class ReplaceableEventService { @@ -745,15 +751,15 @@ export class ReplaceableEventService {
})
if (comprehensiveRelays.length > 0) {
// Query the comprehensive relay list
// 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: 500,
globalTimeout: 10000 // 10 second timeout for comprehensive search
eoseTimeout: 300, // Reduced from 500ms
globalTimeout: 5000 // Reduced from 10000ms to prevent 10s waits
})
const queryTime = Date.now() - startTime
@ -782,6 +788,11 @@ export class ReplaceableEventService { @@ -782,6 +788,11 @@ export class ReplaceableEventService {
})
// Continue to return undefined below
}
} else {
logger.debug('[ReplaceableEventService] Skipping comprehensive search (no relay hints, likely batch fetch)', {
pubkey
})
}
logger.warn('[ReplaceableEventService] Profile not found after trying all relays (including comprehensive search)', {
pubkey,
@ -954,11 +965,13 @@ export class ReplaceableEventService { @@ -954,11 +965,13 @@ export class ReplaceableEventService {
/**
* Fetch following favorite relays
*/
async fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> {
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)
return promise
@ -966,28 +979,47 @@ export class ReplaceableEventService { @@ -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<relayUrl, Set<pubkey>>
const relayToUsers = new Map<string, Set<string>>()
// 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
}
}

Loading…
Cancel
Save