import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getProfileFromEvent } from '@/lib/event-metadata' import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' import { useNostrOptional } from '@/providers/nostr-context' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { eventService, replaceableEventService } from '@/services/client.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service' import indexedDb from '@/services/indexed-db.service' import { TProfile } from '@/types' import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import logger from '@/lib/logger' function feedProfileBatchRetryStaggerMs(pubkeyLower: string): number { return (parseInt(pubkeyLower.slice(0, 8), 16) % 40) * 400 } function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { if (skipCache) return null const pk = pubkey.toLowerCase() const sessionEv = eventService.getSessionMetadataForPubkey(pk) if (sessionEv) { return getProfileFromEvent(sessionEv) } return null } /** Single-flight IndexedDB kind-0 read (never blocks callers until they await the promise). */ function profileFromIdbPromise(pubkey: string, skipCache: boolean): Promise { if (skipCache) return Promise.resolve(null) const pk = pubkey.toLowerCase() return indexedDb .getReplaceableEvent(pk, kinds.Metadata) .then((idbEv) => { if (idbEv && !shouldDropEventOnIngest(idbEv)) { return getProfileFromEvent(idbEv) } return null }) .catch(() => null) } /** * Session LRU + IndexedDB kind 0 without ReplaceableEventService / batched DataLoader. * Used when the hook's fetch race times out or the batch path is slow while disk/session already has metadata. */ async function tryHydrateProfileFromLocalCaches( pubkey: string, skipCache: boolean ): Promise { const fromSession = tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (fromSession) return fromSession return profileFromIdbPromise(pubkey, skipCache) } // CRITICAL: Global deduplication - shared across ALL hook instances // This prevents multiple components from fetching the same profile simultaneously const globalFetchPromises = new Map>() const globalFetchingPubkeys = new Set() // Cooldown period after timeout to prevent cascade of duplicate fetches const globalFetchCooldowns = new Map() // pubkey -> timestamp when cooldown expires export function useFetchProfile(id?: string, skipCache = false) { const nostr = useNostrOptional() const currentAccountProfile = nostr?.profile ?? null const noteFeed = useNoteFeedProfileContext() /** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */ const [isFetching, setIsFetching] = useState(() => { if (!id) return false const pk = userIdToPubkey(id) return !(pk.length === 64 && /^[0-9a-f]{64}$/.test(pk)) }) const [error, setError] = useState(null) const [profile, setProfile] = useState(null) const [pubkey, setPubkey] = useState(null) const checkIntervalRef = useRef(null) const processingPubkeyRef = useRef(null) // Track which pubkey we're currently processing (prevents duplicate fetches) const effectRunCountRef = useRef>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops) const initializedPubkeysRef = useRef>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed) const pkLowerResolved = useMemo(() => { if (!id) return null as string | null const pk = userIdToPubkey(id) if (pk.length !== 64 || !/^[0-9a-f]{64}$/i.test(pk)) return null return pk.toLowerCase() }, [id]) const isPendingInFeed = Boolean( pkLowerResolved && noteFeed?.pendingPubkeys.has(pkLowerResolved) ) /** * Changes when this row's batched profile row appears/updates — **not** on every feed-wide * `version` tick (that remounted thousands of avatars and spammed relay fetches). */ const feedProfileSyncKey = useMemo(() => { if (!pkLowerResolved || !noteFeed) return '' const row = noteFeed.profiles.get(pkLowerResolved) if (!row) return isPendingInFeed ? 'p:wait' : 'p:none' return [ row.batchPlaceholder ? 'ph' : 'ok', row.username ?? '', row.avatar ?? '', row.npub ?? '' ].join('\x1e') }, [pkLowerResolved, noteFeed?.profiles, isPendingInFeed]) // Function to check for profile updates with GLOBAL deduplication // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) // Memoize to prevent recreation on every render const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise => { if (cancelled.current) { return null } // CRITICAL: Check cooldown period first to prevent cascade of duplicate fetches after timeout. // Still hydrate from session/IndexedDB — otherwise new rows remount after a timeout and stay on // identicons until cooldown ends with no effect re-run (deps unchanged). const cooldownExpiry = globalFetchCooldowns.get(pubkey) if (cooldownExpiry && Date.now() < cooldownExpiry) { const cachedDuringCooldown = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) if (!cancelled.current && cachedDuringCooldown) { setProfile(cachedDuringCooldown) setIsFetching(false) initializedPubkeysRef.current.add(pubkey) if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } effectRunCountRef.current.delete(pubkey) return cachedDuringCooldown } return null } // Clean up expired cooldowns if (cooldownExpiry && Date.now() >= cooldownExpiry) { globalFetchCooldowns.delete(pubkey) } // CRITICAL: Check if another hook instance is already fetching this pubkey // If so, wait for that fetch to complete instead of starting a new one // Add timeout protection to prevent infinite waits const existingPromise = globalFetchPromises.get(pubkey) if (existingPromise) { try { // Await the shared promise only — it already races fetchProfileEvent with // PROFILE_FETCH_PROMISE_TIMEOUT_MS. Per-waiter Promise.race timers caused N identical // "timeout" warnings (one per mounted component) and premature map deletion. const existingProfile = await existingPromise if (cancelled.current) return null if (existingProfile) { // Update state for this instance setProfile(existingProfile) setIsFetching(false) initializedPubkeysRef.current.add(pubkey) if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } effectRunCountRef.current.delete(pubkey) return existingProfile } else { setIsFetching(false) return null } } catch (err) { // If the existing promise failed, we'll try again below void err // Clear the failed promise so we can start fresh globalFetchPromises.delete(pubkey) globalFetchingPubkeys.delete(pubkey) } } // Mark as fetching globally to prevent other instances from starting if (globalFetchingPubkeys.has(pubkey)) { // Another instance is fetching, wait a bit and check again await new Promise(resolve => setTimeout(resolve, 50)) const retryPromise = globalFetchPromises.get(pubkey) if (retryPromise) { try { const retryProfile = await retryPromise if (cancelled.current) return null if (retryProfile) { // Update state for this instance setProfile(retryProfile) setIsFetching(false) initializedPubkeysRef.current.add(pubkey) if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } effectRunCountRef.current.delete(pubkey) return retryProfile } else { setIsFetching(false) return null } } catch (err) { void err // Clear the failed promise globalFetchPromises.delete(pubkey) globalFetchingPubkeys.delete(pubkey) // Fall through to start our own fetch } } } // Create a new fetch promise with timeout protection const fetchPromise = (async (): Promise => { let idbEarlyP: Promise | null = null try { globalFetchingPubkeys.add(pubkey) /** Session-only fast path removed: {@link replaceableEventService.fetchProfileEvent} still refreshes from relays while session primes the loader. */ /** Disk read runs in parallel with `fetchProfileEvent` — never block network on IDB. */ idbEarlyP = profileFromIdbPromise(pubkey, skipCache) // CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout) const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject( new Error( `Profile fetch timeout after ${PROFILE_FETCH_PROMISE_TIMEOUT_MS}ms for pubkey ${pubkey.substring(0, 8)}` ) ) }, PROFILE_FETCH_PROMISE_TIMEOUT_MS) }) // Use fetchProfileEvent which includes author's relay list for better profile discovery const profileEvent = await Promise.race([ replaceableEventService.fetchProfileEvent(pubkey, skipCache), timeoutPromise ]) if (profileEvent) { // getProfileFromEvent always returns a profile object (with fallback username) const newProfile = getProfileFromEvent(profileEvent) // CRITICAL: Always return the profile from this shared promise, even when the // originating hook cleaned up (list virtualization, Strict Mode, feed switch). // Returning null here made every waiter treat the result like a timeout, applied // cooldowns, and left avatars empty (especially busy feeds e.g. all-favorites). return newProfile } const afterMiss = (idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (afterMiss) { return afterMiss } return null } catch (err) { const isTimeout = err instanceof Error && err.message.includes('timeout') if (isTimeout) { // Set cooldown period after timeout to prevent cascade of duplicate fetches globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown const fallback = tryHydrateProfileFromSessionOnly(pubkey, skipCache) ?? (idbEarlyP != null ? await idbEarlyP : null) if (fallback) { return fallback } // Return null on timeout instead of throwing - allows UI to show fallback return null } logger.error('[useFetchProfile] Profile fetch error', { pubkey: pubkey.substring(0, 8), error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, cancelled: cancelled.current }) // For non-timeout errors, still throw to allow retry logic throw err } finally { // Clean up global tracking globalFetchingPubkeys.delete(pubkey) // Keep promise in cache for a short time to allow other instances to reuse it // But remove it immediately on timeout/error to allow retries setTimeout(() => { globalFetchPromises.delete(pubkey) }, 1000) // 1 second cache retention } })() // Store the promise globally so other instances can reuse it globalFetchPromises.set(pubkey, fetchPromise) try { const profile = await fetchPromise if (cancelled.current) return null if (profile) { setProfile(profile) setIsFetching(false) // Mark as initialized initializedPubkeysRef.current.add(pubkey) // Keep processingPubkeyRef set so we don't re-fetch // Clear interval once we have a profile if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } // Clear run count when profile is found effectRunCountRef.current.delete(pubkey) return profile } if (!cancelled.current) { setIsFetching(false) } return null } catch (err) { if (!cancelled.current) { setError(err as Error) setIsFetching(false) } return null } }, [skipCache]) useEffect(() => { if (!id) { setProfile(null) setPubkey(null) setIsFetching(false) setError(new Error('No id provided')) processingPubkeyRef.current = null return } const extractedPubkey = userIdToPubkey(id) // Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending. if (extractedPubkey && noteFeed && !skipCache) { const pkL = extractedPubkey.toLowerCase() const fromBatch = noteFeed.profiles.get(pkL) ?? noteFeed.profiles.get(extractedPubkey) if (fromBatch && !fromBatch.batchPlaceholder) { setProfile(fromBatch) setPubkey(extractedPubkey) setIsFetching(false) setError(null) processingPubkeyRef.current = extractedPubkey initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) return } if (fromBatch?.batchPlaceholder) { initializedPubkeysRef.current.delete(extractedPubkey) setProfile(fromBatch) setPubkey(extractedPubkey) setIsFetching(false) setError(null) } if (noteFeed.pendingPubkeys.has(pkL)) { const sessionEv = eventService.getSessionMetadataForPubkey(pkL) if (sessionEv) { const quick = getProfileFromEvent(sessionEv) setProfile(quick) setPubkey(extractedPubkey) setIsFetching(false) setError(null) processingPubkeyRef.current = extractedPubkey initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) return } setPubkey(extractedPubkey) setIsFetching(false) setError(null) const pendingCancelled = { current: false } void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => { if (pendingCancelled.current || !quick) return setProfile(quick) setIsFetching(false) setError(null) processingPubkeyRef.current = extractedPubkey initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) }) const pendingEscapeTimer = window.setTimeout(() => { if (pendingCancelled.current) return const s2 = eventService.getSessionMetadataForPubkey(pkL) if (s2) { const q = getProfileFromEvent(s2) setProfile(q) setIsFetching(false) setError(null) processingPubkeyRef.current = extractedPubkey initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) return } void checkProfile(extractedPubkey, pendingCancelled) }, FEED_PROFILE_PENDING_BATCH_ESCAPE_MS) return () => { pendingCancelled.current = true window.clearTimeout(pendingEscapeTimer) if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } if (extractedPubkey) { effectRunCountRef.current.delete(extractedPubkey) } } } if (fromBatch?.batchPlaceholder) { const placeholderCancelled = { current: false } const staggerMs = feedProfileBatchRetryStaggerMs(pkL) const placeholderTimer = window.setTimeout(() => { if (placeholderCancelled.current) return void checkProfile(extractedPubkey, placeholderCancelled) }, staggerMs) return () => { placeholderCancelled.current = true window.clearTimeout(placeholderTimer) if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } if (extractedPubkey) { effectRunCountRef.current.delete(extractedPubkey) } } } } // Userbadge → profile panel: feed row already had this profile, but secondary stack is outside NoteFeedProfileContext. if (extractedPubkey && !skipCache) { const fromNavigation = getSeededProfileForNavigation(extractedPubkey) if (fromNavigation) { setProfile(fromNavigation) setPubkey(extractedPubkey) setIsFetching(false) setError(null) processingPubkeyRef.current = extractedPubkey initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) return } } // Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag). if (extractedPubkey) { if (processingPubkeyRef.current === extractedPubkey) { const sharedPromise = globalFetchPromises.get(extractedPubkey) const busy = Boolean(sharedPromise) || globalFetchingPubkeys.has(extractedPubkey) || isFetching if (busy) return if (profile?.pubkey === extractedPubkey && !profile.batchPlaceholder) return } processingPubkeyRef.current = extractedPubkey } if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { if (processingPubkeyRef.current !== extractedPubkey) { processingPubkeyRef.current = extractedPubkey } initializedPubkeysRef.current.add(extractedPubkey) if (isFetching) { setIsFetching(false) } effectRunCountRef.current.delete(extractedPubkey) return } if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile) { if (skipCache) { initializedPubkeysRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) } else { if (isFetching) { setIsFetching(false) } return } } if (extractedPubkey) { const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 const pkLower = extractedPubkey.toLowerCase() const feedBatchPlaceholder = noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true || noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 6 : 3 if (runCount >= maxRunsBeforeCircuitBreak) { logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', { extractedPubkey, runCount }) setTimeout(() => { effectRunCountRef.current.delete(extractedPubkey) }, 30000) processingPubkeyRef.current = null if (isFetching) setIsFetching(false) return } effectRunCountRef.current.set(extractedPubkey, runCount + 1) } if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) { const oldPubkey = processingPubkeyRef.current effectRunCountRef.current.delete(oldPubkey) initializedPubkeysRef.current.delete(oldPubkey) processingPubkeyRef.current = null } const cancelled = { current: false } if (!extractedPubkey) { setProfile(null) setPubkey(null) setIsFetching(false) setError(new Error('Invalid id: could not extract pubkey')) processingPubkeyRef.current = null return } if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) { setProfile(null) setPubkey(null) setIsFetching(false) setError(new Error(`Invalid pubkey format: expected 64 hex chars, got ${extractedPubkey.length}`)) processingPubkeyRef.current = null return } if (processingPubkeyRef.current !== extractedPubkey) { logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', { extractedPubkey, processingPubkey: processingPubkeyRef.current }) processingPubkeyRef.current = extractedPubkey } if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { setIsFetching(false) effectRunCountRef.current.delete(extractedPubkey) return } if (pubkey !== extractedPubkey) { setPubkey(extractedPubkey) } const run = async () => { try { setError(null) const earlyProfile = tryHydrateProfileFromSessionOnly(extractedPubkey, skipCache) ?? (await profileFromIdbPromise(extractedPubkey, skipCache)) if (!cancelled.current && earlyProfile) { setProfile(earlyProfile) setIsFetching(false) } else if (!cancelled.current) { setIsFetching(true) } const profile = await checkProfile(extractedPubkey, cancelled) if (cancelled.current) { setIsFetching(false) return } if (profile) { return } setIsFetching(false) setError(null) if (skipCache) { let checkCount = 0 const maxChecks = 3 const startTime = Date.now() const maxTotalTime = 20000 checkIntervalRef.current = setInterval(async () => { const elapsed = Date.now() - startTime if (elapsed > maxTotalTime) { if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } return } if (cancelled.current || checkCount >= maxChecks) { if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } return } checkCount++ const profile = await checkProfile(extractedPubkey, cancelled) if (profile || cancelled.current) { if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } } }, 10000) } } catch (err) { logger.error('[useFetchProfile] run() error', { pubkey: extractedPubkey, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }) if (!cancelled.current) { setError(err as Error) setIsFetching(false) } } } run().catch((err) => { logger.error('[useFetchProfile] Unhandled error in run()', { pubkey: extractedPubkey, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }) }) return () => { cancelled.current = true if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } if (extractedPubkey) { effectRunCountRef.current.delete(extractedPubkey) } } }, [id, skipCache, feedProfileSyncKey, checkProfile]) useEffect(() => { const acc = currentAccountProfile const accPk = acc?.pubkey if (!accPk || !id) return const targetPk = userIdToPubkey(id) if (targetPk.length !== 64 || !/^[0-9a-f]{64}$/i.test(targetPk)) return if (targetPk !== accPk.toLowerCase()) return const profilePk = profile?.pubkey?.trim() const haveFullLocal = !!profilePk && /^[0-9a-f]{64}$/i.test(profilePk) && normalizeHexPubkey(profilePk) === targetPk && !profile?.batchPlaceholder if (haveFullLocal) return setProfile(acc) setIsFetching(false) setError(null) processingPubkeyRef.current = targetPk initializedPubkeysRef.current.add(targetPk) if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } effectRunCountRef.current.delete(targetPk) }, [currentAccountProfile, id, profile]) const profileRefreshCancelledRef = useRef(false) useEffect(() => { profileRefreshCancelledRef.current = false return () => { profileRefreshCancelledRef.current = true } }, [pkLowerResolved]) useEffect(() => { if (!pkLowerResolved) return const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => { const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase() if (detailPk !== pkLowerResolved) return void checkProfile(pkLowerResolved, { current: profileRefreshCancelledRef.current }) } window.addEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) return () => window.removeEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) }, [pkLowerResolved, checkProfile]) return { isFetching, error, profile } }