From 6c622f870edcbed4a6521531e46a89f7e2f30b83 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 1 Nov 2025 07:02:56 +0100 Subject: [PATCH] make profile feed more performant --- src/components/Profile/ProfileArticles.tsx | 24 +++++-- .../Profile/ProfileBookmarksAndHashtags.tsx | 14 ++-- src/components/Profile/ProfileFeed.tsx | 25 +++++-- src/services/client.service.ts | 68 +++++++++++++++++-- 4 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/components/Profile/ProfileArticles.tsx b/src/components/Profile/ProfileArticles.tsx index faf5f24..590e0ee 100644 --- a/src/components/Profile/ProfileArticles.tsx +++ b/src/components/Profile/ProfileArticles.tsx @@ -101,6 +101,17 @@ const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event // Sort by creation time (newest first) eventsToShow.sort((a, b) => b.created_at - a.created_at) + // If initial load returns 0 events but it's not a retry, wait and retry once + // This handles cases where relays return "too many concurrent REQS" and return empty results + if (!isRetry && !isRefresh && eventsToShow.length === 0 && retryCount === 0) { + console.log('[ProfileArticles] Got 0 events on initial load, retrying after delay...') + setTimeout(() => { + setRetryCount(prev => prev + 1) + fetchArticles(true) + }, 2000) // Wait 2 seconds before retry to let relays recover + return + } + if (isRefresh) { // For refresh, append new events and deduplicate setEvents(prevEvents => { @@ -188,16 +199,17 @@ const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event }, [events, searchQuery, kindFilter]) // Separate effect for initial fetch only with a small delay + // Separate effect for initial fetch only - delay slightly to avoid race conditions with other tabs useEffect(() => { if (pubkey) { - // Add a small delay to let the component fully mount and relays to be ready - const timer = setTimeout(() => { + // Small delay to stagger initial fetches across tabs and allow relay list cache to populate + const timeoutId = setTimeout(() => { fetchArticles() - }, 500) // 500ms delay - - return () => clearTimeout(timer) + }, 150) // 150ms delay (slightly longer than posts) to allow previous fetches to populate cache + return () => clearTimeout(timeoutId) } - }, [pubkey]) // Only depend on pubkey to avoid loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pubkey]) // Only depend on pubkey - fetchArticles is stable from useCallback if (isLoading || isRetrying) { return ( diff --git a/src/components/Profile/ProfileBookmarksAndHashtags.tsx b/src/components/Profile/ProfileBookmarksAndHashtags.tsx index 14cc413..b41e28c 100644 --- a/src/components/Profile/ProfileBookmarksAndHashtags.tsx +++ b/src/components/Profile/ProfileBookmarksAndHashtags.tsx @@ -378,19 +378,19 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { refresh }), [refresh]) - // Fetch data when component mounts or pubkey changes with a small delay + // Fetch data when component mounts or pubkey changes - delay slightly to avoid race conditions useEffect(() => { if (pubkey) { - // Add a small delay to let the component fully mount and relays to be ready - const timer = setTimeout(() => { + // Small delay to stagger initial fetches and allow relay list cache to populate + const timeoutId = setTimeout(() => { fetchBookmarks() fetchHashtags() fetchPins() - }, 500) // 500ms delay - - return () => clearTimeout(timer) + }, 200) // 200ms delay (longest since this component does 3 fetches) to allow previous fetches to populate cache + return () => clearTimeout(timeoutId) } - }, [pubkey]) // Only depend on pubkey to avoid loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pubkey]) // Only depend on pubkey - fetch functions are stable from useCallback // Check if the requested tab has content const hasContent = useMemo(() => { diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index 7fc6364..3bfbad3 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -99,6 +99,17 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(({ pub // Sort by creation time (newest first) eventsToShow.sort((a, b) => b.created_at - a.created_at) + // If initial load returns 0 events but it's not a retry, wait and retry once + // This handles cases where relays return "too many concurrent REQS" and return empty results + if (!isRetry && !isRefresh && eventsToShow.length === 0 && retryCount === 0) { + console.log('[ProfileFeed] Got 0 events on initial load, retrying after delay...') + setTimeout(() => { + setRetryCount(prev => prev + 1) + fetchPosts(true) + }, 2000) // Wait 2 seconds before retry to let relays recover + return + } + if (isRefresh) { // For refresh, append new events and deduplicate setEvents(prevEvents => { @@ -166,17 +177,17 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(({ pub ) }, [events, searchQuery]) - // Separate effect for initial fetch only with a small delay + // Separate effect for initial fetch only - delay slightly to avoid race conditions with other tabs useEffect(() => { if (pubkey) { - // Add a small delay to let the component fully mount and relays to be ready - const timer = setTimeout(() => { + // Small delay to stagger initial fetches across tabs and allow relay list cache to populate + const timeoutId = setTimeout(() => { fetchPosts() - }, 500) // 500ms delay - - return () => clearTimeout(timer) + }, 100) // 100ms delay to allow previous fetches to populate cache + return () => clearTimeout(timeoutId) } - }, [pubkey]) // Only depend on pubkey to avoid loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pubkey]) // Only depend on pubkey - fetchPosts is stable from useCallback if (isLoading || isRetrying) { return ( diff --git a/src/services/client.service.ts b/src/services/client.service.ts index af064ce..92be61a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -52,6 +52,7 @@ class ClientService extends EventTarget { > = {} private replaceableEventCacheMap = new Map() private eventCacheMap = new Map>() + private relayListRequestCache = new Map>() // Cache in-flight relay list requests private eventDataLoader = new DataLoader( (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))), { cacheMap: this.eventCacheMap } @@ -755,21 +756,62 @@ class ClientService extends EventTarget { private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { return await new Promise((resolve) => { const events: NEvent[] = [] + let hasEosed = false + let resolveTimeout: ReturnType | null = null + + const resolveWithEvents = () => { + if (resolveTimeout) { + clearTimeout(resolveTimeout) + resolveTimeout = null + } + sub.close() + resolve(events) + } + const sub = this.subscribe(urls, filter, { onevent(evt) { onevent?.(evt) events.push(evt) + // If we got events, clear any timeout - we're making progress + if (resolveTimeout) { + clearTimeout(resolveTimeout) + resolveTimeout = null + } }, oneose: (eosed) => { if (eosed) { - sub.close() - resolve(events) + hasEosed = true + // Wait a bit more after EOSE to ensure we got all events + resolveTimeout = setTimeout(() => { + resolveWithEvents() + }, 500) } }, onclose: () => { - resolve(events) + // Only resolve immediately on close if we've received EOSE or have events + // Otherwise, wait a bit to see if more events come + if (hasEosed || events.length > 0) { + if (resolveTimeout) { + clearTimeout(resolveTimeout) + } + resolve(events) + } else { + // Wait up to 3 seconds for events if connection closes early + resolveTimeout = setTimeout(() => { + resolve(events) + }, 3000) + } } }) + + // Fallback timeout: resolve after 10 seconds max to prevent hanging + setTimeout(() => { + if (resolveTimeout) { + clearTimeout(resolveTimeout) + } + sub.close() + resolve(events) + }, 10000) }) } @@ -1240,8 +1282,24 @@ class ClientService extends EventTarget { } async fetchRelayList(pubkey: string): Promise { - const [relayList] = await this.fetchRelayLists([pubkey]) - return relayList + // Deduplicate concurrent requests for the same pubkey's relay list + const existingRequest = this.relayListRequestCache.get(pubkey) + if (existingRequest) { + return existingRequest + } + + const requestPromise = (async () => { + try { + const [relayList] = await this.fetchRelayLists([pubkey]) + return relayList + } finally { + // Remove from cache after completion (cache result in replaceableEventCacheMap) + this.relayListRequestCache.delete(pubkey) + } + })() + + this.relayListRequestCache.set(pubkey, requestPromise) + return requestPromise } async fetchRelayLists(pubkeys: string[]): Promise {