From 2cb3601df874938cdd42f996897cb8f86d2f9c64 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Mar 2026 00:22:59 +0100 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 222 ++++++++++++------ src/components/QuoteList/index.tsx | 49 +++- .../RelayInfo/RelayReviewsPreview.tsx | 83 ++++--- src/hooks/useFetchProfile.tsx | 63 +++-- src/pages/primary/SpellsPage/index.tsx | 28 ++- src/services/client.service.ts | 108 +++++++-- 6 files changed, 374 insertions(+), 179 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 475ec745..2a81ffd7 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -101,6 +101,13 @@ const NoteList = forwardRef( }))) }, [subRequests]) + // Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds + // Use sorted array and JSON.stringify to create a stable key that only changes when content changes + const showKindsKey = useMemo(() => { + if (!showKinds || showKinds.length === 0) return '' + return JSON.stringify([...showKinds].sort((a, b) => a - b)) + }, [showKinds]) + const shouldHideEvent = useCallback( (evt: Event) => { const pinnedEventHexIdSet = new Set() @@ -325,11 +332,11 @@ const NoteList = forwardRef( 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 + // CRITICAL: Prefetch profiles for initial events (optimized for faster initial load) + // Only prefetch for first 50 events to reduce initial load time + // Additional prefetching happens on scroll via the useEffect hooks const initialPubkeys = Array.from( - new Set(events.slice(0, 150).map((ev: Event) => ev.pubkey).filter((p: string) => p?.length === 64)) + new Set(events.slice(0, 50).map((ev: Event) => ev.pubkey).filter((p: string) => p?.length === 64)) ) if (initialPubkeys.length > 0) { // Filter out already prefetched pubkeys @@ -337,18 +344,20 @@ const NoteList = forwardRef( 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)) - }) + // Batch fetch in background (non-blocking) with delay to not block initial render + setTimeout(() => { + client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { + // On error, remove from prefetched set so we can retry later + pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + }) + }, 100) } } - // CRITICAL: Prefetch embedded events for initial events - // Extract embedded event IDs from initial events + // CRITICAL: Prefetch embedded events for initial events (reduced scope) + // Only prefetch for first 50 events to reduce initial load time const initialEmbeddedEventIds = new Set() - events.slice(0, 150).forEach((ev: Event) => { + events.slice(0, 50).forEach((ev: Event) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id)) }) @@ -358,11 +367,13 @@ const NoteList = forwardRef( 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)) - }) + // Batch fetch embedded events in background (non-blocking) with delay + setTimeout(() => { + 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)) + }) + }, 200) } } else if (eosed) { // No events received but EOSE - set empty events array and stop loading @@ -372,15 +383,22 @@ const NoteList = forwardRef( } if (areAlgoRelays) { + // Algorithm feeds typically return all results at once setHasMore(false) - } - if (eosed) { + } else if (eosed) { setLoading(false) - // CRITICAL FIX: Always set hasMore to true on eosed, even if we have few events + // CRITICAL FIX: For non-algo feeds, always assume there might be more 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) + // If we got a full limit of events, there's likely more available + if (events.length >= (areAlgoRelays ? ALGO_LIMIT : LIMIT)) { + setHasMore(true) + } else { + // Even with fewer events, there might be more (filtering, slow relays, etc.) + // Let loadMore determine if we've reached the end + setHasMore(true) + } } }, onNew: (event: Event) => { @@ -467,7 +485,7 @@ const NoteList = forwardRef( return () => { promise.then((closer) => closer?.()) } - }, [subRequestsKey, refreshCount, showKinds, showKind1OPs, showKind1Replies, showKind1111, useFilterAsIs]) + }, [subRequestsKey, refreshCount, showKindsKey, showKind1OPs, showKind1Replies, showKind1111, useFilterAsIs]) // Use refs to avoid dependency issues and ensure latest values in async callbacks const eventsRef = useRef(events) @@ -499,8 +517,9 @@ const NoteList = forwardRef( useEffect(() => { const options: IntersectionObserverInit = { root: null, - rootMargin: '10px', - threshold: 0.1 + // Trigger when user is 400px from the bottom so we start loading before they reach the end + rootMargin: '0px 0px 400px 0px', + threshold: 0 } const loadMore = async (): Promise => { @@ -578,12 +597,14 @@ const NoteList = forwardRef( // This prevents stopping due to temporary relay issues or slow relays consecutiveEmptyRef.current += 1 - // CRITICAL FIX: Only stop if we have MANY consecutive empty results + // CRITICAL FIX: Only stop if we have MANY consecutive empty results AND we have a reasonable number of events // This ensures we don't stop prematurely when relays are slow or filtering is aggressive - // Even with few visible events, we might have many events that are filtered out - if (consecutiveEmptyRef.current >= 20) { - // After 20 consecutive empty results, assume we've reached the end - // Increased from 10 to 20 to be even more patient with slow relays + // If we have very few events (< 50), keep trying longer in case filtering is aggressive + const eventCount = latestEvents.length + const shouldStop = consecutiveEmptyRef.current >= (eventCount < 50 ? 30 : 15) + + if (shouldStop) { + // After many consecutive empty results, assume we've reached the end setHasMore(false) } // Otherwise, keep hasMore true to allow retry on next scroll @@ -597,15 +618,35 @@ const NoteList = forwardRef( setEvents((oldEvents) => [...oldEvents, ...newEvents]) + // After appending, the bottom sentinel may have moved below the fold. Re-check after + // paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll. + setTimeout(() => { + const bottomEl = bottomRef.current + if (bottomEl && hasMoreRef.current && !loadingRef.current) { + const rect = bottomEl.getBoundingClientRect() + if (rect.top < window.innerHeight + 200) { + loadMore() + } + } + }, 150) + // NEVER automatically set hasMore to false based on result count // Only stop when we get consecutive empty results // This ensures the feed continues loading even with partial results - // CRITICAL: Prefetch profiles for newly loaded events (throttled to reduce frequency) - // This ensures profiles are ready before user scrolls to them - if (newEvents.length > 0) { - // Throttle profile prefetching for newly loaded events to reduce network load - setTimeout(() => { + // CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering) + // Only prefetch if we're not currently loading to avoid blocking scroll + if (newEvents.length > 0 && !loadingRef.current) { + // Use requestIdleCallback if available, otherwise setTimeout with longer delay + const schedulePrefetch = (callback: () => void) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(callback, { timeout: 500 }) + } else { + setTimeout(callback, 300) + } + } + + schedulePrefetch(() => { const newPubkeys = Array.from( new Set(newEvents.map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) @@ -623,9 +664,10 @@ const NoteList = forwardRef( } } - // CRITICAL: Prefetch embedded events for newly loaded events + // CRITICAL: Prefetch embedded events for newly loaded events (throttled) const newEmbeddedEventIds = new Set() - newEvents.forEach((ev) => { + // Only prefetch for first 30 events to reduce load + newEvents.slice(0, 30).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => newEmbeddedEventIds.add(id)) }) @@ -641,7 +683,7 @@ const NoteList = forwardRef( eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) } - }, 100) // Small delay to batch with other profile fetches + }) } } catch (error) { // On error, don't set hasMore to false - might be temporary network issue @@ -741,17 +783,17 @@ const NoteList = forwardRef( clearTimeout(prefetchTimeoutRef.current) } - // Debounce profile prefetching by 200ms to reduce frequency during rapid scrolling + // Debounce profile prefetching by 300ms to reduce frequency during rapid scrolling prefetchTimeoutRef.current = setTimeout(() => { // Prefetch profiles for: - // 1. Currently visible events (first 60, reduced from 80) - // 2. Upcoming events that will be visible when scrolling (next 150, reduced from 300) + // 1. Currently visible events (first 40, reduced to reduce stuttering) + // 2. Upcoming events that will be visible when scrolling (next 80, reduced to reduce load) // This ensures profiles are ready before they're needed during rapid scrolling const visiblePubkeys = Array.from( - new Set(filteredEvents.slice(0, 60).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) + new Set(filteredEvents.slice(0, 40).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) const upcomingPubkeys = Array.from( - new Set(events.slice(0, 150).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) + new Set(events.slice(0, 80).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) // Combine visible and upcoming, but prioritize visible ones @@ -779,11 +821,22 @@ const NoteList = forwardRef( // Batch fetch profiles for new pubkeys (IndexedDB + network in one request) // This is the key optimization: batch processing prevents individual fetches during scrolling - client.fetchProfilesForPubkeys(newPubkeys).catch(() => { - // On error, remove from prefetched set so we can retry later - newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + // Use requestIdleCallback if available to avoid blocking scroll + const scheduleFetch = (callback: () => void) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(callback, { timeout: 500 }) + } else { + setTimeout(callback, 0) + } + } + + scheduleFetch(() => { + client.fetchProfilesForPubkeys(newPubkeys).catch(() => { + // On error, remove from prefetched set so we can retry later + newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + }) }) - }, 200) // Debounce by 200ms to reduce frequency + }, 300) // Debounce by 300ms to reduce frequency during rapid scrolling return () => { if (prefetchTimeoutRef.current) { @@ -801,18 +854,18 @@ const NoteList = forwardRef( clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) } - // Debounce embedded event prefetching by 300ms to reduce frequency during rapid scrolling + // Debounce embedded event prefetching by 400ms to reduce frequency during rapid scrolling prefetchEmbeddedEventsTimeoutRef.current = setTimeout(() => { - // Extract embedded event IDs from visible events (first 60) + // Extract embedded event IDs from visible events (first 40, reduced to reduce load) const visibleEmbeddedEventIds = new Set() - filteredEvents.slice(0, 60).forEach((ev) => { + filteredEvents.slice(0, 40).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => visibleEmbeddedEventIds.add(id)) }) - // Also extract from upcoming events (next 150) + // Also extract from upcoming events (next 80, reduced to reduce load) const upcomingEmbeddedEventIds = new Set() - events.slice(0, 150).forEach((ev) => { + events.slice(0, 80).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => upcomingEmbeddedEventIds.add(id)) }) @@ -835,11 +888,22 @@ const NoteList = forwardRef( 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)) + // Use requestIdleCallback if available to avoid blocking scroll + const scheduleFetch = (callback: () => void) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(callback, { timeout: 500 }) + } else { + setTimeout(callback, 0) + } + } + + scheduleFetch(() => { + 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)) + }) }) - }, 300) // Debounce by 300ms to reduce frequency + }, 400) // Debounce by 400ms to reduce frequency during rapid scrolling return () => { if (prefetchEmbeddedEventsTimeoutRef.current) { @@ -860,12 +924,12 @@ const NoteList = forwardRef( clearTimeout(prefetchNewEventsTimeoutRef.current) } - // Debounce profile prefetching for newly loaded events + // Debounce profile prefetching for newly loaded events (optimized to reduce stuttering) prefetchNewEventsTimeoutRef.current = setTimeout(() => { // When we have more events loaded, prefetch profiles for the newly loaded ones - // Reduced from 200 to 100 to reduce batch size + // Reduced to 50 to reduce batch size and prevent stuttering const newlyLoadedPubkeys = Array.from( - new Set(events.slice(showCount, showCount + 100).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) + new Set(events.slice(showCount, showCount + 50).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) if (newlyLoadedPubkeys.length > 0) { @@ -876,17 +940,27 @@ const NoteList = forwardRef( // Mark as prefetched immediately to prevent duplicate requests newPubkeys.forEach((p) => prefetchedPubkeysRef.current.add(p)) - // Batch fetch in background (non-blocking) - client.fetchProfilesForPubkeys(newPubkeys).catch(() => { - // On error, remove from prefetched set so we can retry later - newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + // Batch fetch in background (non-blocking) using requestIdleCallback + const scheduleFetch = (callback: () => void) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(callback, { timeout: 500 }) + } else { + setTimeout(callback, 0) + } + } + + scheduleFetch(() => { + client.fetchProfilesForPubkeys(newPubkeys).catch(() => { + // On error, remove from prefetched set so we can retry later + newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) + }) }) } } - // CRITICAL: Prefetch embedded events for newly loaded events + // CRITICAL: Prefetch embedded events for newly loaded events (reduced scope) const newlyLoadedEmbeddedEventIds = new Set() - events.slice(showCount, showCount + 100).forEach((ev) => { + events.slice(showCount, showCount + 50).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => newlyLoadedEmbeddedEventIds.add(id)) }) @@ -896,13 +970,23 @@ const NoteList = forwardRef( 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)) + // Batch fetch embedded events in background (non-blocking) using requestIdleCallback + const scheduleFetch = (callback: () => void) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(callback, { timeout: 500 }) + } else { + setTimeout(callback, 0) + } + } + + scheduleFetch(() => { + 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)) + }) }) } - }, 300) // Debounce by 300ms to reduce frequency during rapid scrolling + }, 400) // Debounce by 400ms to reduce frequency during rapid scrolling return () => { if (prefetchNewEventsTimeoutRef.current) { diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx index 9bea8ac9..13b180c9 100644 --- a/src/components/QuoteList/index.tsx +++ b/src/components/QuoteList/index.tsx @@ -82,7 +82,10 @@ export default function QuoteList({ event, className }: { event: Event; classNam } if (eosed) { setLoading(false) - setHasMore(events.length > 0) + // CRITICAL FIX: Always assume there might be more events + // Even if we got fewer events than the limit, there might be more due to filtering + // The loadMore logic will handle stopping when we've truly reached the end + setHasMore(true) } }, onNew: (event) => { @@ -123,17 +126,41 @@ export default function QuoteList({ event, className }: { event: Event; classNam if (!timelineKey || loading || !hasMore) return setLoading(true) - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - setLoading(false) - if (newEvents.length === 0) { - setHasMore(false) - return + try { + const newEvents = await client.loadMoreTimeline( + timelineKey, + events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), + LIMIT + ) + + // CRITICAL FIX: Be more conservative about stopping + // Check if timeline has more cached refs that we haven't loaded yet + if (newEvents.length === 0) { + const until = events.length ? events[events.length - 1].created_at - 1 : dayjs().unix() + const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false + + if (hasMoreCached) { + // There are more cached events, keep hasMore true and try again + setLoading(false) + setTimeout(() => { + if (hasMore && !loading) { + loadMore() + } + }, 300) + return + } + + // No more events available, stop loading + setHasMore(false) + } else { + setEvents((oldEvents) => [...oldEvents, ...newEvents]) + } + } catch (error) { + // On error, don't set hasMore to false - might be temporary network issue + console.error('[QuoteList] Error loading more events', error) + } finally { + setLoading(false) } - setEvents((oldEvents) => [...oldEvents, ...newEvents]) } const observerInstance = new IntersectionObserver((entries) => { diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx index bd4d3411..c52659b2 100644 --- a/src/components/RelayInfo/RelayReviewsPreview.tsx +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -52,46 +52,67 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) }, [myReview, reviews]) useEffect(() => { + let cancelled = false const init = async () => { - const filters: Filter[] = [ - { kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 } - ] - if (pubkey) { - filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] }) - } - const events = await queryService.fetchEvents([relayUrl, ...FAST_READ_RELAY_URLS], filters) + try { + const filters: Filter[] = [ + { kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 } + ] + if (pubkey) { + filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] }) + } + // Use FAST_READ_RELAY_URLS first so we don't block on a slow/failing relay; + // add relayUrl as fallback so we still get reviews from the relay itself. + const relayUrls = [...FAST_READ_RELAY_URLS, relayUrl] + const events = await queryService.fetchEvents(relayUrls, filters, { + eoseTimeout: 3000, + globalTimeout: 6000 + }) - const pubkeySet = new Set() - const reviews: NostrEvent[] = [] - let myReview: NostrEvent | null = null + if (cancelled) return - events.sort((a, b) => compareEvents(b, a)) - for (const evt of events) { - if ( - mutePubkeySet.has(evt.pubkey) || - pubkeySet.has(evt.pubkey) || - (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) - ) { - continue - } - const stars = getStarsFromRelayReviewEvent(evt) - if (!stars) { - continue + const pubkeySet = new Set() + const reviewsList: NostrEvent[] = [] + let myReviewEvt: NostrEvent | null = null + + events.sort((a, b) => compareEvents(b, a)) + for (const evt of events) { + if ( + mutePubkeySet.has(evt.pubkey) || + pubkeySet.has(evt.pubkey) || + (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) + ) { + continue + } + const stars = getStarsFromRelayReviewEvent(evt) + if (!stars) { + continue + } + + pubkeySet.add(evt.pubkey) + if (evt.pubkey === pubkey) { + myReviewEvt = evt + } else { + reviewsList.push(evt) + } } - pubkeySet.add(evt.pubkey) - if (evt.pubkey === pubkey) { - myReview = evt - } else { - reviews.push(evt) + setMyReview(myReviewEvt) + setReviews(reviewsList) + } catch (_) { + // Don't block UI: show "No reviews yet" so feed and rest of page stay usable + if (!cancelled) { + setMyReview(null) + setReviews([]) } + } finally { + if (!cancelled) setInitialized(true) } - - setMyReview(myReview) - setReviews(reviews) - setInitialized(true) } init() + return () => { + cancelled = true + } }, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted]) const handleReviewed = (evt: NostrEvent) => { diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 025c5b88..675607a3 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -59,23 +59,24 @@ export function useFetchProfile(id?: string, skipCache = false) { // 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', { + logger.warn('[useFetchProfile] Existing promise timeout, not starting duplicate 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 timeout won: do NOT start a new fetch (avoids pile-up of parallel fetches for same pubkey). + // Return null so caller can show fallback; the original fetch may still complete and update cache. 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) { + return null + } + if (existingProfile) { // Update state for this instance setProfile(existingProfile) setIsFetching(false) @@ -112,22 +113,22 @@ export function useFetchProfile(id?: string, skipCache = false) { // Add timeout protection here too const timeoutPromise = new Promise((resolve) => { setTimeout(() => { - logger.warn('[useFetchProfile] Retry promise timeout, will start new fetch', { + logger.warn('[useFetchProfile] Retry promise timeout, not starting duplicate 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) { + return null + } + if (retryProfile) { // Update state for this instance setProfile(retryProfile) setIsFetching(false) @@ -277,17 +278,19 @@ export function useFetchProfile(id?: string, skipCache = false) { }, [skipCache]) useEffect(() => { - // CRITICAL: Reduce logging - only log when actually processing, not on every render - // logger.info('[useFetchProfile] useEffect triggered', { - // id: id || 'undefined', - // skipCache, - // processingPubkey: processingPubkeyRef.current, - // hasProfile: !!profile, - // profilePubkey: profile?.pubkey - // }) - + // Early exit when id is missing (e.g. truncated or undefined) - use debug to avoid console spam + if (!id) { + logger.debug('[useFetchProfile] No id provided') + setProfile(null) + setPubkey(null) + setIsFetching(false) + setError(new Error('No id provided')) + processingPubkeyRef.current = null + return + } + // Extract pubkey early to check if id has changed - const extractedPubkey = id ? userIdToPubkey(id) : null + const extractedPubkey = userIdToPubkey(id) // CRITICAL: Early exit if already processing this exact pubkey - prevents infinite loops // This check must happen FIRST, before any other logic @@ -368,16 +371,6 @@ export function useFetchProfile(id?: string, skipCache = false) { processingPubkeyRef.current = null } - if (!id) { - logger.warn('[useFetchProfile] No id provided') - setProfile(null) - setPubkey(null) - setIsFetching(false) - setError(new Error('No id provided')) - processingPubkeyRef.current = null - return - } - const cancelled = { current: false } // CRITICAL: Reduce logging during rapid scrolling - only log at debug level logger.debug('[useFetchProfile] Extracting pubkey', { @@ -551,8 +544,8 @@ export function useFetchProfile(id?: string, skipCache = false) { } } - logger.info('[useFetchProfile] About to call run()', { - pubkey: extractedPubkey + logger.debug('[useFetchProfile] About to call run()', { + pubkey: extractedPubkey?.substring(0, 8) }) run().catch((err) => { logger.error('[useFetchProfile] Unhandled error in run()', { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 2e518cc5..932555dd 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -505,6 +505,26 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells]) const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells]) + // Memoize showKinds to prevent NoteList from re-subscribing when array reference changes + // Create stable key from 'k' tags for dependency + const showKindsTagKey = useMemo(() => { + if (!selectedSpell) return '' + return selectedSpell.tags + .filter((tag) => tag[0] === 'k') + .map((tag) => tag[1]) + .sort() + .join(',') + }, [selectedSpell?.id]) + + const showKinds = useMemo(() => { + if (!selectedSpell) return [1] + const kinds = selectedSpell.tags + .filter((tag) => tag[0] === 'k') + .map((tag) => parseInt(tag[1], 10)) + .filter((n) => !Number.isNaN(n)) + return kinds.length ? kinds : [1] + }, [selectedSpell?.id, showKindsTagKey]) + const spellMenuLabel = useCallback( (spell: Event) => (favoriteIds.has(spell.id) ? `★ ${getSpellName(spell)}` : getSpellName(spell)), [favoriteIds] @@ -851,13 +871,7 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { ) : subRequests.length > 0 ? ( { - const kinds = selectedSpell.tags - .filter((tag) => tag[0] === 'k') - .map((tag) => parseInt(tag[1], 10)) - .filter((n) => !Number.isNaN(n)) - return kinds.length ? kinds : [1] - })()} + showKinds={showKinds} useFilterAsIs /> ) : !pubkey && diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9ea5dc9f..5c7fae86 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1181,7 +1181,19 @@ class ClientService extends EventTarget { ) { const relays = Array.from(new Set(urls)) const key = this.generateTimelineKey(relays, filter) - const timeline = this.timelines[key] + let timeline = this.timelines[key] + + // CRITICAL FIX: Always initialize timeline object, even when useCache is false + // This ensures refs are always available for pagination tracking + if (!timeline || Array.isArray(timeline)) { + this.timelines[key] = { + refs: [], + filter, + urls: relays + } + timeline = this.timelines[key] + } + let cachedEvents: NEvent[] = [] let since: number | undefined // CRITICAL: Only use cache if explicitly enabled (for profile timelines) @@ -1226,14 +1238,26 @@ class ClientService extends EventTarget { let events: NEvent[] = [] let eosedAt: number | null = null let initialBatchScheduled = false - const PROGRESSIVE_DELAY_MS = 0 - const PROGRESSIVE_INTERVAL_MS = 200 + let lastDeliveredCount = 0 + // CRITICAL FIX: Faster progressive loading - show results as soon as we have them + // Reduced delays to improve perceived performance + const PROGRESSIVE_DELAY_MS = 0 // Show first batch immediately + const PROGRESSIVE_INTERVAL_MS = 100 // Check for new events every 100ms (reduced from 200ms) + const MIN_NEW_EVENTS = 5 // Deliver when we have at least 5 new events let progressiveIntervalId: ReturnType | null = null const deliverProgressive = () => { if (eosedAt || events.length === 0) return - const snap = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) - // Only include cached events if caching is enabled - onEvents(needSort && useCache ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false) + const sortedEvents = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) + const newEventCount = sortedEvents.length - lastDeliveredCount + + // Only deliver if we have significantly more events than last time + // This reduces unnecessary re-renders while still showing progress quickly + if (newEventCount >= MIN_NEW_EVENTS || sortedEvents.length >= filter.limit * 0.5) { + lastDeliveredCount = sortedEvents.length + const snap = sortedEvents + // Only include cached events if caching is enabled + onEvents(needSort && useCache ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false) + } } const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, { startLogin, @@ -1243,14 +1267,15 @@ class ClientService extends EventTarget { if (!eosedAt) { events.push(evt) // Deliver first batch quickly so UI doesn't wait for all relays to EOSE - if (needSort && events.length > 0 && !initialBatchScheduled) { + // CRITICAL FIX: Show results immediately when we have enough events + if (needSort && events.length >= MIN_NEW_EVENTS && !initialBatchScheduled) { initialBatchScheduled = true - setTimeout(() => { - deliverProgressive() - if (!progressiveIntervalId) { - progressiveIntervalId = setInterval(deliverProgressive, PROGRESSIVE_INTERVAL_MS) - } - }, PROGRESSIVE_DELAY_MS) + // Deliver immediately for better perceived performance + deliverProgressive() + // Then continue checking for more events + if (!progressiveIntervalId) { + progressiveIntervalId = setInterval(deliverProgressive, PROGRESSIVE_INTERVAL_MS) + } } return } @@ -1386,8 +1411,7 @@ class ClientService extends EventTarget { const { filter, urls, refs } = timeline - // 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 + // Try to load from cache if refs exist let cachedEvents: NEvent[] = [] if (refs && refs.length > 0) { const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) @@ -1403,8 +1427,19 @@ class ClientService extends EventTarget { } } + // CRITICAL FIX: Always query relay for more events, even if we have some cached + // This ensures we continue fetching from relays when scrolling, not just from cache + // Calculate the correct until timestamp based on what we already have until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until limit = limit - cachedEvents.length + + // CRITICAL: Ensure we always query the relay, even if limit is small + // This prevents the feed from stopping when we have few cached events + if (limit <= 0) { + limit = 100 // Minimum limit to ensure we get more events from relay + } + + // Query relay for more events with proper until parameter for pagination let events = await this.query(urls, { ...filter, until, limit }) events.forEach((evt) => { this.addEventToCache(evt) @@ -1417,21 +1452,42 @@ class ClientService extends EventTarget { timeline.refs = [] } - // Prevent concurrent requests from duplicating the same event - // Only filter by lastRefCreatedAt if refs exist and have items + // Prevent duplicate events in refs + const existingRefIds = new Set(timeline.refs.map(([id]) => id)) + const newRefs: TTimelineRef[] = [] + + // Add cached events to refs if not already present + for (const evt of cachedEvents) { + if (!existingRefIds.has(evt.id)) { + newRefs.push([evt.id, evt.created_at]) + existingRefIds.add(evt.id) + } + } + + // Add new events from relay to refs + for (const evt of events) { + if (!existingRefIds.has(evt.id)) { + newRefs.push([evt.id, evt.created_at]) + existingRefIds.add(evt.id) + } + } + + // Sort new refs by created_at descending and merge with existing refs + newRefs.sort((a, b) => b[1] - a[1]) + + // Merge with existing refs, maintaining sorted order 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) - ) + // Only add events that are older than the last ref (for pagination) + const olderRefs = newRefs.filter(([, createdAt]) => createdAt < lastRefCreatedAt) + timeline.refs.push(...olderRefs) + // Keep refs sorted + timeline.refs.sort((a, b) => b[1] - a[1]) } else { - // No existing refs, add all events - timeline.refs.push( - ...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef) - ) + // No existing refs, add all new refs + timeline.refs.push(...newRefs) } + return [...cachedEvents, ...events] }