diff --git a/docs/performance-fixes.md b/docs/performance-fixes.md new file mode 100644 index 00000000..1bc814ca --- /dev/null +++ b/docs/performance-fixes.md @@ -0,0 +1,182 @@ +# Performance fixes (audit follow-up) + +This document records seven fixes identified during a sluggishness audit. They are ordered by impact and should be applied in sequence. + +## Executive summary + +Sluggishness came mainly from **main-thread work that scales with feed size** and **React context churn** that re-renders large subtrees. Orphaned files added maintenance noise but did not affect runtime. + +The largest win is in `NoteList`: expensive per-event work ran inside a filter that scans up to **2,500 events** on every dependency change. + +--- + +## Fix 1 — Hoist `pinnedEventHexIdSet` out of `shouldHideEvent` + +**File:** `src/components/NoteList/index.tsx` + +**Problem:** `shouldHideEvent` rebuilt `pinnedEventHexIdSet` (with `decode()` per pin) **inside** the per-event callback. That callback is used in a `useMemo` that scans up to `MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE` (2500) rows, so pin decoding ran up to 2,500× per filter pass. + +**Fix:** Compute `pinnedEventHexIdSet` once in a `useMemo` keyed on `pinnedEventIds`. `shouldHideEvent` reads the memoized set. + +**Expected impact:** Largest feed-filter win; small diff. + +--- + +## Fix 2 — Cap profile-prefetch walks to visible + lookahead + +**File:** `src/components/NoteList/index.tsx` + +**Problem:** A debounced `useEffect` walked **all** of `timelineEventsForFilter` and `newEvents` on every buffer change, then enqueued profile/emoji prefetch for every author found. + +**Fix:** Walk only `filteredEvents.slice(0, showCount + prefetchMargin)` (capped, e.g. 120 rows) plus a small slice of `newEvents` (e.g. 32). Keep the existing note-stats slice for visible rows. + +**Expected impact:** Fewer redundant kind-0 fetches and less main-thread work when the timeline buffer grows. + +--- + +## Fix 3 — Memoize `MuteListProvider` and `ContentPolicyProvider` context values + +**Files:** +- `src/providers/MuteListProvider.tsx` +- `src/providers/ContentPolicyProvider.tsx` + +**Problem:** Both providers passed a new `value` object every render. Handler functions were recreated each render. Consumers include `NoteList`, `Note`, `Image`, `VideoPlayer`, etc., so parent re-renders cascaded through the feed. + +**Fix:** +- Wrap mute handlers in `useCallback`. +- Wrap policy updaters in `useCallback`. +- Wrap provider `value` in `useMemo` with stable dependencies. + +**Expected impact:** Fewer wide subtree re-renders when unrelated parent state changes. + +--- + +## Fix 4 — Cap `iterateProfileEvents` IndexedDB scan + +**File:** `src/services/indexed-db.service.ts` + +**Problem:** `iterateProfileEvents` loaded **all** kind-0 rows from IndexedDB into a single `events[]` array before chunked callback processing. Large offline caches caused a big allocation on first @-mention search or session prewarm. + +**Fix:** Cap rows collected during the cursor pass (`MAX_PROFILE_EVENTS_ITERATE`, e.g. 8,000). Log when truncated. Keep chunked `requestAnimationFrame` yields during callback processing. + +**Expected impact:** Bounded memory and faster startup on accounts with very large profile caches. + +--- + +## Fix 5 — Tune `ReplaceableEventService` batching during scroll + +**Files:** +- `src/lib/scroll-activity.service.ts` (new) +- `src/components/NoteList/index.tsx` +- `src/services/client-replaceable-events.service.ts` + +**Problem:** `maxBatchSize: 200` with 100ms batching queued many kind-0 fetches during fast scrolling, competing with timeline REQs (console: `Large batch detected, limiting processing`). + +**Fix:** +- Add a lightweight scroll-activity signal (`markScrolling()` from feed scroll handlers). +- Lower `maxBatchSize` (64) and lengthen `batchScheduleFn` delay while scrolling (200ms vs 100ms idle). + +**Expected impact:** Profile fetches defer during scroll; timeline paint stays responsive. + +--- + +## Fix 6 — Delete unused files and junk + +**Problem:** Knip reported 16 unused files plus `src/services/Untitled` (accidental single-character junk). No runtime cost, but bundle/clarity noise. + +**Files removed:** + +| Cluster | Files | +|---------|--------| +| Connected relays UI | `ActiveRelaysDropdownSection.tsx`, `ActiveRelaysIconGrid.tsx`, `active-relays-display.ts` | +| Old Explore tabs | `Explore/index.tsx`, `ExploreFavoriteRelays.tsx`, `ExplorePopularRelays.tsx`, `ExploreRelayReviews.tsx` | +| Other UI | `FollowingFavoriteRelayList`, `SeenOnButton`, `NotificationThreadWatchButtons`, `ProfileTimeline`, `Tabs/index.tsx`, `ProfileSearchBar`, `QuickZapSwitch` | +| Hooks | `useBtcUsdRate.ts`, `useRelayConnectionRows.ts` | +| Junk | `src/services/Untitled` | + +**Note:** `useProfileTimeline` hook and `ExploreRelayDirectory` remain in use; only dead wrappers were removed. + +--- + +## Fix 7 — YouTube iframe API poll timeout + +**File:** `src/lib/youtube-iframe-api.ts` + +**Problem:** When the YouTube script tag was already present but `YT.Player` never became available, `requestAnimationFrame` polled forever. + +**Fix:** Cap polling (e.g. 5s / ~300 frames). Resolve the promise anyway so embeds fail gracefully instead of leaking rAF work. + +--- + +## Verification + +After applying all fixes: + +```bash +npm run test -- --run src/lib/profile-author-warmup-spec.test.ts src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts +npx tsc --noEmit +``` + +Manual checks: + +1. Home feed scroll — no jank spike when many authors are visible. +2. @-mention search — still finds cached profiles after IDB cap. +3. YouTube embeds — still load when API is healthy; no infinite rAF when blocked. + +--- + +## Phase 2 — Archives API was additive, not a replacement (2026-06-07) + +**Why it still felt sluggish:** Nostr Archives REST was wired in parallel with relays for profiles/search, but **feed note stats still fired full relay REQ waves** for every visible card even when Archives already had interaction counts. Archives also consumed the shared **100 req/min** budget via sequential interaction prefetch, while relay work continued unchanged. + +### Fix A — Feed cards: Archives-only stats + +**File:** `src/components/NoteStats/index.tsx` + +Background feed cards (`!foregroundStats`) now call `prefetchArchivesInteractions` only. Relay `fetchNoteStats` runs on foreground surfaces (open note, thread, interactions panel). + +### Fix B — Parallel Archives interaction prefetch + +**File:** `src/lib/note-stats-archives-prefetch.ts` + +Prefetch runs with concurrency 5 instead of one note at a time. + +### Fix C — Archives-first profile batch + +**File:** `src/lib/profile-metadata-batch.ts` + +Await Archives metadata (400ms cap), then relay-fetch **only pubkeys Archives did not return** — avoids duplicate kind-0 storms. + +### Fix D — Remove 120× `subscribeNoteStats` in NoteList + +**File:** `src/components/NoteList/index.tsx` + +Dropped per-visible-note stats subscriptions that re-triggered profile batches on every Archives count update. + +### Fix E — Stabilize superchat filter + +**File:** `src/components/NoteList/index.tsx` + +`feedAttestedSuperchatIds` read from a ref inside `shouldHideEvent` so attestation updates do not rescan 2500 rows. + +### Fix F — Cap Archives blocking on thread resolve + +**File:** `src/lib/thread-context-local.ts` + +Archives event lookup races with a 700ms cap before falling through to local stores / relays. + +--- + +## Related audit items (not in this pass) + +| Area | Notes | +|------|--------| +| `DeepBrowsingProvider` + `Tabs` | Scroll updates context → tab chrome re-renders | +| `NostrProvider` | Account hydration causes broad tree updates | +| `useFeedAttestedSuperchatIds` | New attestations invalidate `shouldHideEvent` → full feed re-scan | +| `NoteList` stats subscriptions | Up to ~120 `subscribeNoteStats` listeners | +| `RssFeedList` | 20s IndexedDB poll while mounted | +| `client.service` | 45s HTTP timeline polling on index relays | +| Knip unused exports | Mostly shadcn re-exports; low priority | + +The `while (true)` worker pool in `client.service.ts` (~line 390) is intentional and bounded. diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index c152fbc9..76c5fab1 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -11,7 +11,7 @@ import { import { TImetaInfo } from '@/types' import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash' import { decode } from 'blurhash' -import { ImageOff } from 'lucide-react' +import { Image as ImageIcon, ImageOff } from 'lucide-react' import { CSSProperties, HTMLAttributes, @@ -332,16 +332,21 @@ export default function Image({ } const hasHoverTip = Boolean(imgTitle) + const showTapToRevealChrome = !showErrorState && !revealed && effectiveHoldUntilClick + const tapToRevealLabel = t('Click to load image') return ( @@ -375,6 +380,19 @@ export default function Image({ )} )} + {showTapToRevealChrome && ( + <> + + + + + + + + )} {!showErrorState && revealed && ( { + feedAttestedSuperchatIdsRef.current = feedAttestedSuperchatIds + }, [feedAttestedSuperchatIds]) const followingFeedDeltaSubRequestsKey = useMemo( () => @@ -1427,7 +1431,7 @@ const NoteList = forwardRef( if ( !shouldIncludePaymentInFeed( evt, - feedAttestedSuperchatIds, + feedAttestedSuperchatIdsRef.current, incomingPaymentRecipientPubkey ) ) { @@ -1460,7 +1464,6 @@ const NoteList = forwardRef( mutePubkeySet, pinnedEventHexIdSet, isEventDeleted, - feedAttestedSuperchatIds, incomingPaymentRecipientPubkey, extraShouldHideEvent, homeFeedActiveSeenOnAllowlist, @@ -1758,15 +1761,6 @@ const NoteList = forwardRef( clientFilteredVisibleCountRef.current = clientFilteredEvents.length }, [clientFilteredEvents.length]) - const visibleNoteIdsForStatsPrefetchKey = useMemo( - () => - clientFilteredEvents - .slice(0, Math.min(120, Math.max(showCount + 64, 64))) - .map((e) => e.id) - .join('\n'), - [clientFilteredEvents, showCount] - ) - const enqueueFeedProfilePubkeys = useCallback((need: string[]) => { if (need.length === 0) return const gen = feedProfileBatchGenRef.current @@ -1830,51 +1824,6 @@ const NoteList = forwardRef( })() }, []) - const statsProfilePrefetchDebounceRef = useRef | null>(null) - const pendingStatsProfilePubkeysRef = useRef>(new Set()) - - useEffect(() => { - if (!visibleNoteIdsForStatsPrefetchKey) return - const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean) - - const flushStatsProfiles = () => { - statsProfilePrefetchDebounceRef.current = null - const need = [...pendingStatsProfilePubkeysRef.current].filter( - (pk) => !feedProfileLoadedRef.current.has(pk) - ) - pendingStatsProfilePubkeysRef.current.clear() - enqueueFeedProfilePubkeys(need) - } - - const onStatsUpdate = (noteId: string) => { - const candidates = new Set() - collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(noteId), candidates) - for (const pk of candidates) { - if (!feedProfileLoadedRef.current.has(pk)) { - pendingStatsProfilePubkeysRef.current.add(pk) - } - } - if (pendingStatsProfilePubkeysRef.current.size === 0) return - if (statsProfilePrefetchDebounceRef.current) { - clearTimeout(statsProfilePrefetchDebounceRef.current) - } - statsProfilePrefetchDebounceRef.current = setTimeout( - flushStatsProfiles, - FEED_PROFILE_BATCH_DEBOUNCE_MS - ) - } - - const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, () => onStatsUpdate(id))) - return () => { - unsubs.forEach((u) => u()) - if (statsProfilePrefetchDebounceRef.current) { - clearTimeout(statsProfilePrefetchDebounceRef.current) - statsProfilePrefetchDebounceRef.current = null - } - pendingStatsProfilePubkeysRef.current.clear() - } - }, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys]) - const clientFilteredNewEvents = useMemo( () => showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 08fde738..91b08167 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -116,6 +116,8 @@ export default function NoteStats({ if (!fetchIfNotExisting) return if (shouldDeferStatsFetch && !isNearViewport) return noteStatsService.prefetchArchivesInteractions(event.id) + /** Feed cards: Archives aggregate counts are enough for badges — skip relay REQ storms. */ + if (!foregroundStats) return setLoading(true) noteStatsService .fetchNoteStats(event, pubkey, statsRelaysRef.current, { diff --git a/src/lib/note-stats-archives-prefetch.ts b/src/lib/note-stats-archives-prefetch.ts index 7a10d912..7551e994 100644 --- a/src/lib/note-stats-archives-prefetch.ts +++ b/src/lib/note-stats-archives-prefetch.ts @@ -4,6 +4,7 @@ import type { TArchivesInteractionCounts } from '@/types/nostr-archives' const BATCH_DELAY_MS = 48 const MAX_BATCH_SIZE = 20 +const PREFETCH_CONCURRENCY = 5 const RECENT_TTL_MS = 5 * 60_000 const pending = new Set() @@ -53,19 +54,26 @@ async function flushBatch(): Promise { pending.delete(id) } - for (const id of batch) { - if (!nostrArchivesApi.isAvailable()) break - inFlight.add(id) - try { - const res = await nostrArchivesApi.getEventInteractions(id) - if (res.ok) { - markRecent(id) - noteStatsService.applyArchivesInteractionCounts(id, res.data) + let cursor = 0 + const worker = async () => { + while (cursor < batch.length) { + if (!nostrArchivesApi.isAvailable()) return + const id = batch[cursor++]! + inFlight.add(id) + try { + const res = await nostrArchivesApi.getEventInteractions(id) + if (res.ok) { + markRecent(id) + noteStatsService.applyArchivesInteractionCounts(id, res.data) + } + } finally { + inFlight.delete(id) } - } finally { - inFlight.delete(id) } } + await Promise.all( + Array.from({ length: Math.min(PREFETCH_CONCURRENCY, batch.length) }, () => worker()) + ) if (pending.size > 0) scheduleBatch() } diff --git a/src/lib/profile-metadata-batch.ts b/src/lib/profile-metadata-batch.ts index cda50bb1..0f89ae76 100644 --- a/src/lib/profile-metadata-batch.ts +++ b/src/lib/profile-metadata-batch.ts @@ -53,14 +53,24 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr try { const byPk = new Map() - const relayPromise = client.fetchProfilesForPubkeys(deduped).catch(() => [] as TProfile[]) const archivesPromise = nostrArchivesApi.isAvailable() ? nostrArchivesApi.fetchProfilesMetadata(deduped) : Promise.resolve({ ok: false as const, reason: 'disabled' as const }) - const [archivesRes, relayProfiles] = await Promise.all([archivesPromise, relayPromise]) + const archivesRes = await Promise.race([ + archivesPromise, + new Promise<{ ok: false; reason: 'timeout' }>((resolve) => + setTimeout(() => resolve({ ok: false, reason: 'timeout' }), 400) + ) + ]) mergeArchivesProfiles(byPk, archivesRes) + const relayNeeded = deduped.filter((pk) => !byPk.has(pk)) + const relayProfiles = + relayNeeded.length > 0 + ? await client.fetchProfilesForPubkeys(relayNeeded).catch(() => [] as TProfile[]) + : [] + for (const p of relayProfiles) { const pkNorm = p.pubkey.toLowerCase() if (!byPk.has(pkNorm)) { diff --git a/src/lib/thread-context-local.ts b/src/lib/thread-context-local.ts index 3a64174d..b8605dce 100644 --- a/src/lib/thread-context-local.ts +++ b/src/lib/thread-context-local.ts @@ -56,7 +56,10 @@ export async function resolveThreadContextEventFromLocalStores( return fromArchive } - const fromArchivesApi = await resolveNoteEventFromArchives(hex) + const fromArchivesApi = await Promise.race([ + resolveNoteEventFromArchives(hex), + new Promise((resolve) => setTimeout(resolve, 700)) + ]) if (fromArchivesApi && !shouldDropEventOnIngest(fromArchivesApi, { explicitNoteLookupHexId: hex })) { return fromArchivesApi }