Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
2cb3601df8
  1. 180
      src/components/NoteList/index.tsx
  2. 33
      src/components/QuoteList/index.tsx
  3. 37
      src/components/RelayInfo/RelayReviewsPreview.tsx
  4. 53
      src/hooks/useFetchProfile.tsx
  5. 28
      src/pages/primary/SpellsPage/index.tsx
  6. 96
      src/services/client.service.ts

180
src/components/NoteList/index.tsx

@ -101,6 +101,13 @@ const NoteList = forwardRef(
}))) })))
}, [subRequests]) }, [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( const shouldHideEvent = useCallback(
(evt: Event) => { (evt: Event) => {
const pinnedEventHexIdSet = new Set() const pinnedEventHexIdSet = new Set()
@ -325,11 +332,11 @@ const NoteList = forwardRef(
if (events.length > 0) { if (events.length > 0) {
setEvents(events) setEvents(events)
// CRITICAL: Prefetch profiles for initial events (reduced batch size for faster initial load) // CRITICAL: Prefetch profiles for initial events (optimized for faster initial load)
// This ensures profiles are ready before user starts scrolling // Only prefetch for first 50 events to reduce initial load time
// Reduced from 300 to 150 to reduce initial load time // Additional prefetching happens on scroll via the useEffect hooks
const initialPubkeys = Array.from( 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) { if (initialPubkeys.length > 0) {
// Filter out already prefetched pubkeys // Filter out already prefetched pubkeys
@ -337,18 +344,20 @@ const NoteList = forwardRef(
if (pubkeysToFetch.length > 0) { if (pubkeysToFetch.length > 0) {
// Mark as prefetched immediately to prevent duplicate requests // Mark as prefetched immediately to prevent duplicate requests
pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p)) pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p))
// Batch fetch in background (non-blocking) // Batch fetch in background (non-blocking) with delay to not block initial render
setTimeout(() => {
client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p))
}) })
}, 100)
} }
} }
// CRITICAL: Prefetch embedded events for initial events // CRITICAL: Prefetch embedded events for initial events (reduced scope)
// Extract embedded event IDs from initial events // Only prefetch for first 50 events to reduce initial load time
const initialEmbeddedEventIds = new Set<string>() const initialEmbeddedEventIds = new Set<string>()
events.slice(0, 150).forEach((ev: Event) => { events.slice(0, 50).forEach((ev: Event) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id)) embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id))
}) })
@ -358,11 +367,13 @@ const NoteList = forwardRef(
if (eventIdsToFetch.length > 0) { if (eventIdsToFetch.length > 0) {
// Mark as prefetched immediately to prevent duplicate requests // Mark as prefetched immediately to prevent duplicate requests
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
// Batch fetch embedded events in background (non-blocking) // Batch fetch embedded events in background (non-blocking) with delay
setTimeout(() => {
Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
}) })
}, 200)
} }
} else if (eosed) { } else if (eosed) {
// No events received but EOSE - set empty events array and stop loading // No events received but EOSE - set empty events array and stop loading
@ -372,16 +383,23 @@ const NoteList = forwardRef(
} }
if (areAlgoRelays) { if (areAlgoRelays) {
// Algorithm feeds typically return all results at once
setHasMore(false) setHasMore(false)
} } else if (eosed) {
if (eosed) {
setLoading(false) 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 // 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 // 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) // Only set to false if we explicitly know there are no more events (handled in loadMore)
// 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) setHasMore(true)
} }
}
}, },
onNew: (event: Event) => { onNew: (event: Event) => {
if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (!useFilterAsIs && !showKinds.includes(event.kind)) return
@ -467,7 +485,7 @@ const NoteList = forwardRef(
return () => { return () => {
promise.then((closer) => closer?.()) 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 // Use refs to avoid dependency issues and ensure latest values in async callbacks
const eventsRef = useRef(events) const eventsRef = useRef(events)
@ -499,8 +517,9 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
const options: IntersectionObserverInit = { const options: IntersectionObserverInit = {
root: null, root: null,
rootMargin: '10px', // Trigger when user is 400px from the bottom so we start loading before they reach the end
threshold: 0.1 rootMargin: '0px 0px 400px 0px',
threshold: 0
} }
const loadMore = async (): Promise<void> => { const loadMore = async (): Promise<void> => {
@ -578,12 +597,14 @@ const NoteList = forwardRef(
// This prevents stopping due to temporary relay issues or slow relays // This prevents stopping due to temporary relay issues or slow relays
consecutiveEmptyRef.current += 1 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 // 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 we have very few events (< 50), keep trying longer in case filtering is aggressive
if (consecutiveEmptyRef.current >= 20) { const eventCount = latestEvents.length
// After 20 consecutive empty results, assume we've reached the end const shouldStop = consecutiveEmptyRef.current >= (eventCount < 50 ? 30 : 15)
// Increased from 10 to 20 to be even more patient with slow relays
if (shouldStop) {
// After many consecutive empty results, assume we've reached the end
setHasMore(false) setHasMore(false)
} }
// Otherwise, keep hasMore true to allow retry on next scroll // Otherwise, keep hasMore true to allow retry on next scroll
@ -597,15 +618,35 @@ const NoteList = forwardRef(
setEvents((oldEvents) => [...oldEvents, ...newEvents]) 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 // NEVER automatically set hasMore to false based on result count
// Only stop when we get consecutive empty results // Only stop when we get consecutive empty results
// This ensures the feed continues loading even with partial results // This ensures the feed continues loading even with partial results
// CRITICAL: Prefetch profiles for newly loaded events (throttled to reduce frequency) // CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering)
// This ensures profiles are ready before user scrolls to them // Only prefetch if we're not currently loading to avoid blocking scroll
if (newEvents.length > 0) { if (newEvents.length > 0 && !loadingRef.current) {
// Throttle profile prefetching for newly loaded events to reduce network load // Use requestIdleCallback if available, otherwise setTimeout with longer delay
setTimeout(() => { const schedulePrefetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 300)
}
}
schedulePrefetch(() => {
const newPubkeys = Array.from( const newPubkeys = Array.from(
new Set(newEvents.map((ev) => ev.pubkey).filter((p) => p?.length === 64)) 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<string>() const newEmbeddedEventIds = new Set<string>()
newEvents.forEach((ev) => { // Only prefetch for first 30 events to reduce load
newEvents.slice(0, 30).forEach((ev) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id) => newEmbeddedEventIds.add(id)) embeddedIds.forEach((id) => newEmbeddedEventIds.add(id))
}) })
@ -641,7 +683,7 @@ const NoteList = forwardRef(
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
}) })
} }
}, 100) // Small delay to batch with other profile fetches })
} }
} catch (error) { } catch (error) {
// On error, don't set hasMore to false - might be temporary network issue // On error, don't set hasMore to false - might be temporary network issue
@ -741,17 +783,17 @@ const NoteList = forwardRef(
clearTimeout(prefetchTimeoutRef.current) 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(() => { prefetchTimeoutRef.current = setTimeout(() => {
// Prefetch profiles for: // Prefetch profiles for:
// 1. Currently visible events (first 60, reduced from 80) // 1. Currently visible events (first 40, reduced to reduce stuttering)
// 2. Upcoming events that will be visible when scrolling (next 150, reduced from 300) // 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 // This ensures profiles are ready before they're needed during rapid scrolling
const visiblePubkeys = Array.from( 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( 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 // 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) // Batch fetch profiles for new pubkeys (IndexedDB + network in one request)
// This is the key optimization: batch processing prevents individual fetches during scrolling // This is the key optimization: batch processing prevents individual fetches during scrolling
// 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(() => { client.fetchProfilesForPubkeys(newPubkeys).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) 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 () => { return () => {
if (prefetchTimeoutRef.current) { if (prefetchTimeoutRef.current) {
@ -801,18 +854,18 @@ const NoteList = forwardRef(
clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) 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(() => { 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<string>() const visibleEmbeddedEventIds = new Set<string>()
filteredEvents.slice(0, 60).forEach((ev) => { filteredEvents.slice(0, 40).forEach((ev) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id) => visibleEmbeddedEventIds.add(id)) 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<string>() const upcomingEmbeddedEventIds = new Set<string>()
events.slice(0, 150).forEach((ev) => { events.slice(0, 80).forEach((ev) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id) => upcomingEmbeddedEventIds.add(id)) embeddedIds.forEach((id) => upcomingEmbeddedEventIds.add(id))
}) })
@ -835,11 +888,22 @@ const NoteList = forwardRef(
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
// Batch fetch embedded events in background (non-blocking) // Batch fetch embedded events in background (non-blocking)
// 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(() => { Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) 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 () => { return () => {
if (prefetchEmbeddedEventsTimeoutRef.current) { if (prefetchEmbeddedEventsTimeoutRef.current) {
@ -860,12 +924,12 @@ const NoteList = forwardRef(
clearTimeout(prefetchNewEventsTimeoutRef.current) clearTimeout(prefetchNewEventsTimeoutRef.current)
} }
// Debounce profile prefetching for newly loaded events // Debounce profile prefetching for newly loaded events (optimized to reduce stuttering)
prefetchNewEventsTimeoutRef.current = setTimeout(() => { prefetchNewEventsTimeoutRef.current = setTimeout(() => {
// When we have more events loaded, prefetch profiles for the newly loaded ones // 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( 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) { if (newlyLoadedPubkeys.length > 0) {
@ -876,17 +940,27 @@ const NoteList = forwardRef(
// Mark as prefetched immediately to prevent duplicate requests // Mark as prefetched immediately to prevent duplicate requests
newPubkeys.forEach((p) => prefetchedPubkeysRef.current.add(p)) newPubkeys.forEach((p) => prefetchedPubkeysRef.current.add(p))
// Batch fetch in background (non-blocking) // 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(() => { client.fetchProfilesForPubkeys(newPubkeys).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) 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<string>() const newlyLoadedEmbeddedEventIds = new Set<string>()
events.slice(showCount, showCount + 100).forEach((ev) => { events.slice(showCount, showCount + 50).forEach((ev) => {
const embeddedIds = extractEmbeddedEventIds(ev) const embeddedIds = extractEmbeddedEventIds(ev)
embeddedIds.forEach((id) => newlyLoadedEmbeddedEventIds.add(id)) embeddedIds.forEach((id) => newlyLoadedEmbeddedEventIds.add(id))
}) })
@ -896,13 +970,23 @@ const NoteList = forwardRef(
if (eventIdsToFetch.length > 0) { if (eventIdsToFetch.length > 0) {
// Mark as prefetched immediately to prevent duplicate requests // Mark as prefetched immediately to prevent duplicate requests
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
// Batch fetch embedded events in background (non-blocking) // 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(() => { Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => {
// On error, remove from prefetched set so we can retry later // On error, remove from prefetched set so we can retry later
eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) 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 () => { return () => {
if (prefetchNewEventsTimeoutRef.current) { if (prefetchNewEventsTimeoutRef.current) {

33
src/components/QuoteList/index.tsx

@ -82,7 +82,10 @@ export default function QuoteList({ event, className }: { event: Event; classNam
} }
if (eosed) { if (eosed) {
setLoading(false) 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) => { onNew: (event) => {
@ -123,18 +126,42 @@ export default function QuoteList({ event, className }: { event: Event; classNam
if (!timelineKey || loading || !hasMore) return if (!timelineKey || loading || !hasMore) return
setLoading(true) setLoading(true)
try {
const newEvents = await client.loadMoreTimeline( const newEvents = await client.loadMoreTimeline(
timelineKey, timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT LIMIT
) )
setLoading(false)
// CRITICAL FIX: Be more conservative about stopping
// Check if timeline has more cached refs that we haven't loaded yet
if (newEvents.length === 0) { if (newEvents.length === 0) {
setHasMore(false) 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 return
} }
// No more events available, stop loading
setHasMore(false)
} else {
setEvents((oldEvents) => [...oldEvents, ...newEvents]) 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)
}
}
const observerInstance = new IntersectionObserver((entries) => { const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) { if (entries[0].isIntersecting && hasMore) {

37
src/components/RelayInfo/RelayReviewsPreview.tsx

@ -52,18 +52,28 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
}, [myReview, reviews]) }, [myReview, reviews])
useEffect(() => { useEffect(() => {
let cancelled = false
const init = async () => { const init = async () => {
try {
const filters: Filter[] = [ const filters: Filter[] = [
{ kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 } { kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 }
] ]
if (pubkey) { if (pubkey) {
filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] }) filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] })
} }
const events = await queryService.fetchEvents([relayUrl, ...FAST_READ_RELAY_URLS], filters) // 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
})
if (cancelled) return
const pubkeySet = new Set<string>() const pubkeySet = new Set<string>()
const reviews: NostrEvent[] = [] const reviewsList: NostrEvent[] = []
let myReview: NostrEvent | null = null let myReviewEvt: NostrEvent | null = null
events.sort((a, b) => compareEvents(b, a)) events.sort((a, b) => compareEvents(b, a))
for (const evt of events) { for (const evt of events) {
@ -81,17 +91,28 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
pubkeySet.add(evt.pubkey) pubkeySet.add(evt.pubkey)
if (evt.pubkey === pubkey) { if (evt.pubkey === pubkey) {
myReview = evt myReviewEvt = evt
} else { } else {
reviews.push(evt) reviewsList.push(evt)
} }
} }
setMyReview(myReview) setMyReview(myReviewEvt)
setReviews(reviews) setReviews(reviewsList)
setInitialized(true) } 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)
}
} }
init() init()
return () => {
cancelled = true
}
}, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted]) }, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
const handleReviewed = (evt: NostrEvent) => { const handleReviewed = (evt: NostrEvent) => {

53
src/hooks/useFetchProfile.tsx

@ -59,7 +59,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
// Add timeout to prevent waiting forever on a stuck promise // Add timeout to prevent waiting forever on a stuck promise
const timeoutPromise = new Promise<null>((resolve) => { const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => { 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) pubkey: pubkey.substring(0, 8)
}) })
resolve(null) resolve(null)
@ -69,13 +69,14 @@ export function useFetchProfile(id?: string, skipCache = false) {
const existingProfile = await Promise.race([existingPromise, timeoutPromise]) const existingProfile = await Promise.race([existingPromise, timeoutPromise])
if (cancelled.current) return null 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) { if (existingProfile === null && !cancelled.current) {
// Timeout occurred, clear the stuck promise and start fresh
globalFetchPromises.delete(pubkey) globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey) globalFetchingPubkeys.delete(pubkey)
// Fall through to start new fetch return null
} else if (existingProfile) { }
if (existingProfile) {
// Update state for this instance // Update state for this instance
setProfile(existingProfile) setProfile(existingProfile)
setIsFetching(false) setIsFetching(false)
@ -112,7 +113,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
// Add timeout protection here too // Add timeout protection here too
const timeoutPromise = new Promise<null>((resolve) => { const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => { 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) pubkey: pubkey.substring(0, 8)
}) })
resolve(null) resolve(null)
@ -123,11 +124,11 @@ export function useFetchProfile(id?: string, skipCache = false) {
if (cancelled.current) return null if (cancelled.current) return null
if (retryProfile === null && !cancelled.current) { if (retryProfile === null && !cancelled.current) {
// Timeout occurred, clear and start fresh
globalFetchPromises.delete(pubkey) globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey) globalFetchingPubkeys.delete(pubkey)
// Fall through to start new fetch return null
} else if (retryProfile) { }
if (retryProfile) {
// Update state for this instance // Update state for this instance
setProfile(retryProfile) setProfile(retryProfile)
setIsFetching(false) setIsFetching(false)
@ -277,17 +278,19 @@ export function useFetchProfile(id?: string, skipCache = false) {
}, [skipCache]) }, [skipCache])
useEffect(() => { useEffect(() => {
// CRITICAL: Reduce logging - only log when actually processing, not on every render // Early exit when id is missing (e.g. truncated or undefined) - use debug to avoid console spam
// logger.info('[useFetchProfile] useEffect triggered', { if (!id) {
// id: id || 'undefined', logger.debug('[useFetchProfile] No id provided')
// skipCache, setProfile(null)
// processingPubkey: processingPubkeyRef.current, setPubkey(null)
// hasProfile: !!profile, setIsFetching(false)
// profilePubkey: profile?.pubkey setError(new Error('No id provided'))
// }) processingPubkeyRef.current = null
return
}
// Extract pubkey early to check if id has changed // 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 // CRITICAL: Early exit if already processing this exact pubkey - prevents infinite loops
// This check must happen FIRST, before any other logic // This check must happen FIRST, before any other logic
@ -368,16 +371,6 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = null 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 } const cancelled = { current: false }
// CRITICAL: Reduce logging during rapid scrolling - only log at debug level // CRITICAL: Reduce logging during rapid scrolling - only log at debug level
logger.debug('[useFetchProfile] Extracting pubkey', { logger.debug('[useFetchProfile] Extracting pubkey', {
@ -551,8 +544,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
} }
} }
logger.info('[useFetchProfile] About to call run()', { logger.debug('[useFetchProfile] About to call run()', {
pubkey: extractedPubkey pubkey: extractedPubkey?.substring(0, 8)
}) })
run().catch((err) => { run().catch((err) => {
logger.error('[useFetchProfile] Unhandled error in run()', { logger.error('[useFetchProfile] Unhandled error in run()', {

28
src/pages/primary/SpellsPage/index.tsx

@ -505,6 +505,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells]) const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells])
const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells]) 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( const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)), (spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
[favoriteIds] [favoriteIds]
@ -851,13 +871,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
) : subRequests.length > 0 ? ( ) : subRequests.length > 0 ? (
<NoteList <NoteList
subRequests={subRequests} subRequests={subRequests}
showKinds={(() => { showKinds={showKinds}
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]
})()}
useFilterAsIs useFilterAsIs
/> />
) : !pubkey && ) : !pubkey &&

96
src/services/client.service.ts

@ -1181,7 +1181,19 @@ class ClientService extends EventTarget {
) { ) {
const relays = Array.from(new Set(urls)) const relays = Array.from(new Set(urls))
const key = this.generateTimelineKey(relays, filter) 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 cachedEvents: NEvent[] = []
let since: number | undefined let since: number | undefined
// CRITICAL: Only use cache if explicitly enabled (for profile timelines) // CRITICAL: Only use cache if explicitly enabled (for profile timelines)
@ -1226,15 +1238,27 @@ class ClientService extends EventTarget {
let events: NEvent[] = [] let events: NEvent[] = []
let eosedAt: number | null = null let eosedAt: number | null = null
let initialBatchScheduled = false let initialBatchScheduled = false
const PROGRESSIVE_DELAY_MS = 0 let lastDeliveredCount = 0
const PROGRESSIVE_INTERVAL_MS = 200 // 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<typeof setInterval> | null = null let progressiveIntervalId: ReturnType<typeof setInterval> | null = null
const deliverProgressive = () => { const deliverProgressive = () => {
if (eosedAt || events.length === 0) return if (eosedAt || events.length === 0) return
const snap = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) 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 // Only include cached events if caching is enabled
onEvents(needSort && useCache ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false) onEvents(needSort && useCache ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false)
} }
}
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, { const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
startLogin, startLogin,
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
@ -1243,14 +1267,15 @@ class ClientService extends EventTarget {
if (!eosedAt) { if (!eosedAt) {
events.push(evt) events.push(evt)
// Deliver first batch quickly so UI doesn't wait for all relays to EOSE // 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 initialBatchScheduled = true
setTimeout(() => { // Deliver immediately for better perceived performance
deliverProgressive() deliverProgressive()
// Then continue checking for more events
if (!progressiveIntervalId) { if (!progressiveIntervalId) {
progressiveIntervalId = setInterval(deliverProgressive, PROGRESSIVE_INTERVAL_MS) progressiveIntervalId = setInterval(deliverProgressive, PROGRESSIVE_INTERVAL_MS)
} }
}, PROGRESSIVE_DELAY_MS)
} }
return return
} }
@ -1386,8 +1411,7 @@ class ClientService extends EventTarget {
const { filter, urls, refs } = timeline const { filter, urls, refs } = timeline
// Only try to load from cache if refs exist and we have cached events // Try to load from cache if refs exist
// When useCache is false, refs might be empty or we might not want to use cache
let cachedEvents: NEvent[] = [] let cachedEvents: NEvent[] = []
if (refs && refs.length > 0) { if (refs && refs.length > 0) {
const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) 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 until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until
limit = limit - cachedEvents.length 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 }) let events = await this.query(urls, { ...filter, until, limit })
events.forEach((evt) => { events.forEach((evt) => {
this.addEventToCache(evt) this.addEventToCache(evt)
@ -1417,21 +1452,42 @@ class ClientService extends EventTarget {
timeline.refs = [] timeline.refs = []
} }
// Prevent concurrent requests from duplicating the same event // Prevent duplicate events in refs
// Only filter by lastRefCreatedAt if refs exist and have items 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) { if (timeline.refs.length > 0) {
const lastRefCreatedAt = timeline.refs[timeline.refs.length - 1][1] const lastRefCreatedAt = timeline.refs[timeline.refs.length - 1][1]
timeline.refs.push( // Only add events that are older than the last ref (for pagination)
...events const olderRefs = newRefs.filter(([, createdAt]) => createdAt < lastRefCreatedAt)
.filter((evt) => evt.created_at < lastRefCreatedAt) timeline.refs.push(...olderRefs)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef) // Keep refs sorted
) timeline.refs.sort((a, b) => b[1] - a[1])
} else { } else {
// No existing refs, add all events // No existing refs, add all new refs
timeline.refs.push( timeline.refs.push(...newRefs)
...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
)
} }
return [...cachedEvents, ...events] return [...cachedEvents, ...events]
} }

Loading…
Cancel
Save