Browse Source

speed up feeds by leaning more into NostrArchive API

add a picture button to image fallbacks
imwald
Silberengel 1 week ago
parent
commit
778ad06553
  1. 182
      docs/performance-fixes.md
  2. 22
      src/components/Image/index.tsx
  3. 61
      src/components/NoteList/index.tsx
  4. 2
      src/components/NoteStats/index.tsx
  5. 12
      src/lib/note-stats-archives-prefetch.ts
  6. 14
      src/lib/profile-metadata-batch.ts
  7. 5
      src/lib/thread-context-local.ts

182
docs/performance-fixes.md

@ -0,0 +1,182 @@ @@ -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.

22
src/components/Image/index.tsx

@ -11,7 +11,7 @@ import { @@ -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({ @@ -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 (
<span className={cn('block w-full not-prose', classNames.wrapper)}>
<span
className={cn(
'relative overflow-hidden block w-full rounded-lg bg-background',
showTapToRevealChrome && 'group cursor-zoom-in',
hasHoverTip && 'cursor-help ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)}
style={mergedWrapperStyle}
title={imgTitle}
title={showTapToRevealChrome ? tapToRevealLabel : imgTitle}
role={showTapToRevealChrome ? 'button' : undefined}
aria-label={showTapToRevealChrome ? tapToRevealLabel : undefined}
onClick={handleWrapperClick}
{...props}
>
@ -375,6 +380,19 @@ export default function Image({ @@ -375,6 +380,19 @@ export default function Image({
)}
</span>
)}
{showTapToRevealChrome && (
<>
<span
className="absolute inset-0 z-[15] bg-gradient-to-t from-black/55 via-black/25 to-black/15 pointer-events-none"
aria-hidden
/>
<span className="absolute inset-0 z-[20] grid place-items-center pointer-events-none" aria-hidden>
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105">
<ImageIcon className="size-7" strokeWidth={2} />
</span>
</span>
</>
)}
{!showErrorState && revealed && (
<img
ref={imgRef}

61
src/components/NoteList/index.tsx

@ -1072,6 +1072,10 @@ const NoteList = forwardRef( @@ -1072,6 +1072,10 @@ const NoteList = forwardRef(
}, [subRequestsKey, feedSubscriptionKey])
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
const feedAttestedSuperchatIdsRef = useRef(feedAttestedSuperchatIds)
useEffect(() => {
feedAttestedSuperchatIdsRef.current = feedAttestedSuperchatIds
}, [feedAttestedSuperchatIds])
const followingFeedDeltaSubRequestsKey = useMemo(
() =>
@ -1427,7 +1431,7 @@ const NoteList = forwardRef( @@ -1427,7 +1431,7 @@ const NoteList = forwardRef(
if (
!shouldIncludePaymentInFeed(
evt,
feedAttestedSuperchatIds,
feedAttestedSuperchatIdsRef.current,
incomingPaymentRecipientPubkey
)
) {
@ -1460,7 +1464,6 @@ const NoteList = forwardRef( @@ -1460,7 +1464,6 @@ const NoteList = forwardRef(
mutePubkeySet,
pinnedEventHexIdSet,
isEventDeleted,
feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey,
extraShouldHideEvent,
homeFeedActiveSeenOnAllowlist,
@ -1758,15 +1761,6 @@ const NoteList = forwardRef( @@ -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( @@ -1830,51 +1824,6 @@ const NoteList = forwardRef(
})()
}, [])
const statsProfilePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingStatsProfilePubkeysRef = useRef<Set<string>>(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<string>()
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,

2
src/components/NoteStats/index.tsx

@ -116,6 +116,8 @@ export default function NoteStats({ @@ -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, {

12
src/lib/note-stats-archives-prefetch.ts

@ -4,6 +4,7 @@ import type { TArchivesInteractionCounts } from '@/types/nostr-archives' @@ -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<string>()
@ -53,8 +54,11 @@ async function flushBatch(): Promise<void> { @@ -53,8 +54,11 @@ async function flushBatch(): Promise<void> {
pending.delete(id)
}
for (const id of batch) {
if (!nostrArchivesApi.isAvailable()) break
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)
@ -66,6 +70,10 @@ async function flushBatch(): Promise<void> { @@ -66,6 +70,10 @@ async function flushBatch(): Promise<void> {
inFlight.delete(id)
}
}
}
await Promise.all(
Array.from({ length: Math.min(PREFETCH_CONCURRENCY, batch.length) }, () => worker())
)
if (pending.size > 0) scheduleBatch()
}

14
src/lib/profile-metadata-batch.ts

@ -53,14 +53,24 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr @@ -53,14 +53,24 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr
try {
const byPk = new Map<string, TProfile>()
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)) {

5
src/lib/thread-context-local.ts

@ -56,7 +56,10 @@ export async function resolveThreadContextEventFromLocalStores( @@ -56,7 +56,10 @@ export async function resolveThreadContextEventFromLocalStores(
return fromArchive
}
const fromArchivesApi = await resolveNoteEventFromArchives(hex)
const fromArchivesApi = await Promise.race([
resolveNoteEventFromArchives(hex),
new Promise<undefined>((resolve) => setTimeout(resolve, 700))
])
if (fromArchivesApi && !shouldDropEventOnIngest(fromArchivesApi, { explicitNoteLookupHexId: hex })) {
return fromArchivesApi
}

Loading…
Cancel
Save