diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 694ada81..d25bce2c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -209,85 +209,135 @@ const NoteList = forwardRef( useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useEffect(() => { - if (!subRequests.length) return + logger.info('[NoteList] useEffect triggered', { + subRequestsLength: subRequests.length, + subRequests: subRequests.map(({ urls, filter }) => ({ + urls: urls.slice(0, 2), + filterKeys: Object.keys(filter) + })) + }) + + if (!subRequests.length) { + logger.warn('[NoteList] subRequests is empty, not initializing') + return + } async function init() { + logger.debug('[NoteList] init called', { + subRequestsCount: subRequests.length, + showKindsLength: showKinds.length, + showKinds, + useFilterAsIs, + areAlgoRelays + }) setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests.map(({ urls, filter }) => ({ - urls, - filter: useFilterAsIs - ? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) } - : { - ...filter, - // If showKinds is empty, default to kind 1 (ShortTextNote) only - kinds: showKinds.length > 0 ? showKinds : [kinds.ShortTextNote], - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT - } - })), - { - onEvents: (events, eosed) => { - if (events.length > 0) { - setEvents(events) + const mappedSubRequests = subRequests.map(({ urls, filter }) => ({ + urls, + filter: useFilterAsIs + ? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) } + : { + ...filter, + // If showKinds is empty, default to kind 1 (ShortTextNote) only + kinds: showKinds.length > 0 ? showKinds : [kinds.ShortTextNote], + limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + } + })) + + logger.debug('[NoteList] Subscribing with filters', { + subRequestCount: mappedSubRequests.length, + filters: mappedSubRequests.map(({ urls, filter }) => ({ + urls: urls.slice(0, 2), // Log first 2 URLs + kinds: filter.kinds, + limit: filter.limit + })) + }) + + logger.info('[NoteList] About to call subscribeTimeline', { + mappedSubRequestsCount: mappedSubRequests.length + }) + + let closer: (() => void) | undefined + let timelineKey: string | undefined + + try { + const result = await client.subscribeTimeline( + mappedSubRequests, + { + onEvents: (events: Event[], eosed: boolean) => { + logger.debug('[NoteList] onEvents called', { + eventCount: events.length, + eosed, + showKindsLength: showKinds.length, + subRequestsCount: subRequests.length + }) - // CRITICAL: Prefetch profiles for initial events (reduced batch size for faster initial load) - // This ensures profiles are ready before user starts scrolling - // Reduced from 300 to 150 to reduce initial load time - const initialPubkeys = Array.from( - new Set(events.slice(0, 150).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) - ) - if (initialPubkeys.length > 0) { - // Filter out already prefetched pubkeys - const pubkeysToFetch = initialPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) - if (pubkeysToFetch.length > 0) { + if (events.length > 0) { + setEvents(events) + + // CRITICAL: Prefetch profiles for initial events (reduced batch size for faster initial load) + // This ensures profiles are ready before user starts scrolling + // Reduced from 300 to 150 to reduce initial load time + const initialPubkeys = Array.from( + new Set(events.slice(0, 150).map((ev: Event) => ev.pubkey).filter((p: string) => p?.length === 64)) + ) + if (initialPubkeys.length > 0) { + // Filter out already prefetched pubkeys + const pubkeysToFetch = initialPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) + if (pubkeysToFetch.length > 0) { + // Mark as prefetched immediately to prevent duplicate requests + pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p)) + // Batch fetch in background (non-blocking) + client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { + // On error, remove from prefetched set so we can retry later + pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + }) + } + } + + // CRITICAL: Prefetch embedded events for initial events + // Extract embedded event IDs from initial events + const initialEmbeddedEventIds = new Set() + events.slice(0, 150).forEach((ev: Event) => { + const embeddedIds = extractEmbeddedEventIds(ev) + embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id)) + }) + const eventIdsToFetch = Array.from(initialEmbeddedEventIds).filter( + (id) => !prefetchedEventIdsRef.current.has(id) + ) + if (eventIdsToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests - pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p)) - // Batch fetch in background (non-blocking) - client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { + eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) + // Batch fetch embedded events in background (non-blocking) + Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { // On error, remove from prefetched set so we can retry later - pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) } + } else if (eosed) { + // No events received but EOSE - set empty events array and stop loading + logger.debug('[NoteList] EOSE with no events, stopping loading') + setEvents([]) + setLoading(false) } - // CRITICAL: Prefetch embedded events for initial events - // Extract embedded event IDs from initial events - const initialEmbeddedEventIds = new Set() - events.slice(0, 150).forEach((ev) => { - const embeddedIds = extractEmbeddedEventIds(ev) - embeddedIds.forEach((id) => initialEmbeddedEventIds.add(id)) - }) - const eventIdsToFetch = Array.from(initialEmbeddedEventIds).filter( - (id) => !prefetchedEventIdsRef.current.has(id) - ) - if (eventIdsToFetch.length > 0) { - // Mark as prefetched immediately to prevent duplicate requests - eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) - // Batch fetch embedded events in background (non-blocking) - Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { - // On error, remove from prefetched set so we can retry later - eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) - }) + if (areAlgoRelays) { + setHasMore(false) } - } - if (areAlgoRelays) { - setHasMore(false) - } - if (eosed) { - setLoading(false) - // CRITICAL FIX: Always set hasMore to true on eosed, even if we have few events - // The initial load might only return a few events due to filtering or relay limits - // We should still try to load more on scroll - the loadMore logic will handle stopping - // Only set to false if we explicitly know there are no more events (handled in loadMore) - setHasMore(true) - } - }, - onNew: (event) => { + if (eosed) { + setLoading(false) + // CRITICAL FIX: Always set hasMore to true on eosed, even if we have few events + // The initial load might only return a few events due to filtering or relay limits + // We should still try to load more on scroll - the loadMore logic will handle stopping + // Only set to false if we explicitly know there are no more events (handled in loadMore) + setHasMore(true) + } + }, + onNew: (event: Event) => { if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (event.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(event) @@ -307,7 +357,7 @@ const NoteList = forwardRef( ) } }, - onClose: (url, reason) => { + onClose: (url: string, reason: string) => { if (!showRelayCloseReason) return // ignore reasons from nostr-tools if ( @@ -345,8 +395,22 @@ const NoteList = forwardRef( useCache: false // Main feeds should always fetch fresh from relays, not use cache } ) + closer = result.closer + timelineKey = result.timelineKey + logger.info('[NoteList] subscribeTimeline completed', { + hasTimelineKey: !!timelineKey, + hasCloser: !!closer + }) setTimelineKey(timelineKey) return closer + } catch (error) { + logger.error('[NoteList] Error in subscribeTimeline', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }) + setLoading(false) + throw error + } } const promise = init() diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 11afc3b9..025c5b88 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -6,6 +6,11 @@ import { TProfile } from '@/types' import { useEffect, useState, useRef, useCallback } from 'react' import logger from '@/lib/logger' +// 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() + export function useFetchProfile(id?: string, skipCache = false) { // CRITICAL: Reduce logging to prevent performance issues during infinite loops // Only log if we're actually going to process (not just checking) @@ -25,10 +30,10 @@ export function useFetchProfile(id?: string, skipCache = false) { 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) - // Function to check for profile updates + // 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 }) => { + const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise => { // CRITICAL: Reduce logging during rapid scrolling to prevent performance issues // Only log at debug level during normal operations logger.debug('[useFetchProfile] checkProfile called', { @@ -38,43 +43,212 @@ export function useFetchProfile(id?: string, skipCache = false) { }) if (cancelled.current) { - logger.debug('[useFetchProfile] Already cancelled, returning false') - return false + logger.debug('[useFetchProfile] Already cancelled, returning null') + return null } - try { - const startTime = Date.now() - - // Use fetchProfileEvent which includes author's relay list for better profile discovery - const profileEvent = await replaceableEventService.fetchProfileEvent(pubkey, skipCache) - const fetchTime = Date.now() - startTime - - // Only log at info level if profile was found or if fetch took a long time - if (profileEvent || fetchTime > 1000) { - logger.info('[useFetchProfile] fetchProfileEvent completed', { + // 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) { + logger.debug('[useFetchProfile] Reusing existing fetch promise', { + pubkey: pubkey.substring(0, 8) + }) + try { + // Add timeout to prevent waiting forever on a stuck promise + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + logger.warn('[useFetchProfile] Existing promise timeout, will start new fetch', { + pubkey: pubkey.substring(0, 8) + }) + resolve(null) + }, 5000) // 5 seconds + }) + + const existingProfile = await Promise.race([existingPromise, timeoutPromise]) + if (cancelled.current) return null + + // If timeout won, existingProfile will be null and we'll continue to start new fetch + if (existingProfile === null && !cancelled.current) { + // Timeout occurred, clear the stuck promise and start fresh + globalFetchPromises.delete(pubkey) + globalFetchingPubkeys.delete(pubkey) + // Fall through to start new fetch + } else 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 + logger.debug('[useFetchProfile] Existing promise failed, will retry', { pubkey: pubkey.substring(0, 8), - hasEvent: !!profileEvent, - eventId: profileEvent?.id?.substring(0, 8), - fetchTime: `${fetchTime}ms` + error: err instanceof Error ? err.message : String(err) }) + // Clear the failed promise so we can start fresh + globalFetchPromises.delete(pubkey) + globalFetchingPubkeys.delete(pubkey) } - - if (cancelled.current) { - logger.info('[useFetchProfile] Fetch cancelled after fetch', { pubkey }) - return false + } + + // 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 { + // Add timeout protection here too + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + logger.warn('[useFetchProfile] Retry promise timeout, will start new fetch', { + pubkey: pubkey.substring(0, 8) + }) + resolve(null) + }, 5000) // 5 seconds + }) + + const retryProfile = await Promise.race([retryPromise, timeoutPromise]) + if (cancelled.current) return null + + if (retryProfile === null && !cancelled.current) { + // Timeout occurred, clear and start fresh + globalFetchPromises.delete(pubkey) + globalFetchingPubkeys.delete(pubkey) + // Fall through to start new fetch + } else 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) { + logger.debug('[useFetchProfile] Retry promise failed', { + pubkey: pubkey.substring(0, 8), + error: err instanceof Error ? err.message : String(err) + }) + // Clear the failed promise + globalFetchPromises.delete(pubkey) + globalFetchingPubkeys.delete(pubkey) + // Fall through to start our own fetch + } } - - if (profileEvent) { - // getProfileFromEvent always returns a profile object (with fallback username) - const newProfile = getProfileFromEvent(profileEvent) - // Only log at debug level to reduce noise during rapid scrolling - logger.debug('[useFetchProfile] Profile found', { + } + + // Create a new fetch promise with timeout protection + const fetchPromise = (async (): Promise => { + try { + globalFetchingPubkeys.add(pubkey) + const startTime = Date.now() + + // CRITICAL: Add timeout to prevent infinite hangs + // Use Promise.race to timeout after 5 seconds + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Profile fetch timeout after 5s for pubkey ${pubkey.substring(0, 8)}`)) + }, 5000) // 5 second timeout + }) + + // Use fetchProfileEvent which includes author's relay list for better profile discovery + const profileEvent = await Promise.race([ + replaceableEventService.fetchProfileEvent(pubkey, skipCache), + timeoutPromise + ]) + const fetchTime = Date.now() - startTime + + // Only log at info level if profile was found or if fetch took a long time + if (profileEvent || fetchTime > 1000) { + logger.info('[useFetchProfile] fetchProfileEvent completed', { + pubkey: pubkey.substring(0, 8), + hasEvent: !!profileEvent, + eventId: profileEvent?.id?.substring(0, 8), + fetchTime: `${fetchTime}ms` + }) + } + + if (cancelled.current) { + logger.info('[useFetchProfile] Fetch cancelled after fetch', { pubkey }) + return null + } + + if (profileEvent) { + // getProfileFromEvent always returns a profile object (with fallback username) + const newProfile = getProfileFromEvent(profileEvent) + // Only log at debug level to reduce noise during rapid scrolling + logger.debug('[useFetchProfile] Profile found', { + pubkey: pubkey.substring(0, 8), + username: newProfile.username, + hasAvatar: !!newProfile.avatar, + fetchTime: `${fetchTime}ms` + }) + return newProfile + } + // Only log warnings for missing profiles if skipCache is true (user explicitly requested) + if (skipCache) { + logger.debug('[useFetchProfile] No profile event found', { + pubkey: pubkey.substring(0, 8), + fetchTime: `${fetchTime}ms` + }) + } + return null + } catch (err) { + const isTimeout = err instanceof Error && err.message.includes('timeout') + if (isTimeout) { + logger.warn('[useFetchProfile] Profile fetch timed out', { + pubkey: pubkey.substring(0, 8), + error: err.message + }) + // 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), - username: newProfile.username, - hasAvatar: !!newProfile.avatar, - fetchTime: `${fetchTime}ms` + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + cancelled: cancelled.current }) - setProfile(newProfile) + // 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) @@ -86,28 +260,19 @@ export function useFetchProfile(id?: string, skipCache = false) { } // Clear run count when profile is found effectRunCountRef.current.delete(pubkey) - return true + return profile } - // Only log warnings for missing profiles if skipCache is true (user explicitly requested) - if (skipCache) { - logger.debug('[useFetchProfile] No profile event found', { - pubkey: pubkey.substring(0, 8), - fetchTime: `${fetchTime}ms` - }) + + if (!cancelled.current) { + setIsFetching(false) } - return false + return null } catch (err) { - logger.error('[useFetchProfile] Profile fetch error', { - pubkey, - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, - cancelled: cancelled.current - }) - if (!cancelled.current) { - setError(err as Error) - setIsFetching(false) - } - return false + if (!cancelled.current) { + setError(err as Error) + setIsFetching(false) + } + return null } }, [skipCache]) @@ -289,13 +454,14 @@ export function useFetchProfile(id?: string, skipCache = false) { setError(null) // Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network - const found = await checkProfile(extractedPubkey, cancelled) + // checkProfile now returns the profile directly (or null) and handles global deduplication + const profile = await checkProfile(extractedPubkey, cancelled) // Only log if profile was found or if cancelled (important events) - if (found || cancelled.current) { + if (profile || cancelled.current) { logger.debug('[useFetchProfile] checkProfile completed', { pubkey: extractedPubkey?.substring(0, 8), - found, + found: !!profile, cancelled: cancelled.current }) } @@ -306,8 +472,9 @@ export function useFetchProfile(id?: string, skipCache = false) { return } - if (found) { + if (profile) { // Profile found (from cache or network), we're done + // checkProfile already set the profile state, so we're done return } @@ -327,8 +494,24 @@ export function useFetchProfile(id?: string, skipCache = false) { // This reduces memory usage when many profiles are being fetched (e.g., trending page) let checkCount = 0 const maxChecks = 3 // Reduced from 4 to further reduce load + const startTime = Date.now() + const maxTotalTime = 20000 // 20 seconds total timeout (3 checks * ~5s + buffer) checkIntervalRef.current = setInterval(async () => { + // CRITICAL: Check for timeout to prevent infinite retries + const elapsed = Date.now() - startTime + if (elapsed > maxTotalTime) { + logger.warn('[useFetchProfile] Retry interval timeout reached, stopping retries', { + pubkey: extractedPubkey?.substring(0, 8), + elapsed: `${elapsed}ms` + }) + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null + } + return + } + if (cancelled.current || checkCount >= maxChecks) { if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) @@ -338,8 +521,8 @@ export function useFetchProfile(id?: string, skipCache = false) { } checkCount++ - const found = await checkProfile(extractedPubkey, cancelled) - if (found || cancelled.current) { + const profile = await checkProfile(extractedPubkey, cancelled) + if (profile || cancelled.current) { // Profile found or cancelled, stop checking if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 89c6e49a..316e4019 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -30,9 +30,34 @@ export default function RelaysFeed({ setIsReady(false) return } - const relayInfos = await relayInfoService.getRelayInfos(relayUrls) - setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) - setIsReady(true) + + // Add timeout to prevent hanging if getRelayInfos is slow + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('getRelayInfos timeout after 5 seconds')) + }, 5000) + }) + + try { + const relayInfos = await Promise.race([ + relayInfoService.getRelayInfos(relayUrls), + timeoutPromise + ]) + setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) + setIsReady(true) + logger.debug('RelaysFeed: Initialized successfully', { + relayCount: relayUrls.length, + areAlgoRelays: relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) + }) + } catch (error) { + logger.warn('RelaysFeed: Failed to get relay infos, proceeding anyway', { + error: error instanceof Error ? error.message : String(error), + relayUrls + }) + // Proceed anyway - we can still show the feed even without relay info + setAreAlgoRelays(false) + setIsReady(true) + } } init() }, [relayUrls]) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d7ec2d1c..9ea5dc9f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1259,13 +1259,17 @@ class ClientService extends EventTarget { onNew(evt) } - // Only update timeline cache if caching is enabled - if (!useCache) return - + // Update timeline refs for pagination tracking (even when useCache is false) + // This is needed for loadMoreTimeline to know what events have been loaded const timeline = that.timelines[key] - if (!timeline || Array.isArray(timeline) || !timeline.refs.length) { + if (!timeline || Array.isArray(timeline)) { return } + + // Initialize refs if empty (needed for pagination even when not using cache) + if (!timeline.refs || timeline.refs.length === 0) { + timeline.refs = [] + } // find the right position to insert let idx = 0 @@ -1334,7 +1338,28 @@ class ClientService extends EventTarget { onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true) } } else { - // No caching - just return events directly + // No caching for initial load, but still need to initialize timeline.refs for loadMoreTimeline pagination + const timeline = that.timelines[key] + if (!timeline || Array.isArray(timeline)) { + // Initialize timeline with refs for pagination (even though we don't use cache for initial load) + that.timelines[key] = { + refs: events.map((evt) => [evt.id, evt.created_at]), + filter, + urls + } + } else { + // Update refs with new events for pagination tracking + const firstRefCreatedAt = timeline.refs.length > 0 ? timeline.refs[0][1] : dayjs().unix() + const newRefs = events + .filter((evt) => evt.created_at > firstRefCreatedAt) + .map((evt) => [evt.id, evt.created_at] as TTimelineRef) + if (events.length >= filter.limit) { + timeline.refs = newRefs + } else { + timeline.refs = newRefs.concat(timeline.refs) + } + } + // Return events directly (no cache concatenation) onEvents([...events], true) } }, @@ -1360,17 +1385,22 @@ class ClientService extends EventTarget { if (!timeline || Array.isArray(timeline)) return [] const { filter, urls, refs } = timeline - const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) - const cachedEvents = - startIdx >= 0 - ? (( - await Promise.all( - refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventService.fetchEvent(id)) - ) - ).filter((evt): evt is NEvent => !!evt) as NEvent[]) - : [] - if (cachedEvents.length >= limit) { - return cachedEvents + + // Only try to load from cache if refs exist and we have cached events + // When useCache is false, refs might be empty or we might not want to use cache + let cachedEvents: NEvent[] = [] + if (refs && refs.length > 0) { + const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) + if (startIdx >= 0) { + cachedEvents = ( + await Promise.all( + refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventService.fetchEvent(id)) + ) + ).filter((evt): evt is NEvent => !!evt) as NEvent[] + } + if (cachedEvents.length >= limit) { + return cachedEvents + } } until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until @@ -1381,13 +1411,27 @@ class ClientService extends EventTarget { }) events = events.sort((a, b) => b.created_at - a.created_at).slice(0, limit) + // Update refs for pagination tracking (even when useCache is false) + // Initialize refs if empty + if (!timeline.refs) { + timeline.refs = [] + } + // Prevent concurrent requests from duplicating the same event - const lastRefCreatedAt = refs.length > 0 ? refs[refs.length - 1][1] : dayjs().unix() - timeline.refs.push( - ...events - .filter((evt) => evt.created_at < lastRefCreatedAt) - .map((evt) => [evt.id, evt.created_at] as TTimelineRef) - ) + // Only filter by lastRefCreatedAt if refs exist and have items + if (timeline.refs.length > 0) { + const lastRefCreatedAt = timeline.refs[timeline.refs.length - 1][1] + timeline.refs.push( + ...events + .filter((evt) => evt.created_at < lastRefCreatedAt) + .map((evt) => [evt.id, evt.created_at] as TTimelineRef) + ) + } else { + // No existing refs, add all events + timeline.refs.push( + ...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef) + ) + } return [...cachedEvents, ...events] }