From 0851cb03e98581f269e876ad60563204610e5b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Tue, 24 Mar 2026 11:49:33 +0100 Subject: [PATCH] perf: reduce Map allocations and subscription churn in REQ timeline hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip duplicate events in setEventsMap (return prev if event.id exists) - Only create new relayStates Map on actual state transitions (waiting→receiving), not on every event — counter increments applied in-place - Don't add unknown relays to the state map (skip defensive init) - Cap streaming eventsMap at 2000 with 25% batch eviction of oldest events - Decouple relay filter map from subscription lifecycle: store in ref, only tear down subscriptions when the relay SET changes (not filter content) - Use useStableRelayFilterMap for structural comparison instead of JSON.stringify Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useReqTimelineEnhanced.ts | 103 +++++++++++++++++++--------- 1 file changed, 70 insertions(+), 33 deletions(-) diff --git a/src/hooks/useReqTimelineEnhanced.ts b/src/hooks/useReqTimelineEnhanced.ts index f97516e..bb13c43 100644 --- a/src/hooks/useReqTimelineEnhanced.ts +++ b/src/hooks/useReqTimelineEnhanced.ts @@ -3,11 +3,20 @@ import pool from "@/services/relay-pool"; import type { NostrEvent, Filter } from "nostr-tools"; import { useEventStore } from "applesauce-react/hooks"; import { isNostrEvent } from "@/lib/type-guards"; -import { useStableValue, useStableArray } from "./useStable"; +import { + useStableValue, + useStableArray, + useStableRelayFilterMap, +} from "./useStable"; import { useRelayState } from "./useRelayState"; import type { ReqRelayState, ReqOverallState } from "@/types/req-state"; import { deriveOverallState } from "@/lib/req-state-machine"; +/** Maximum events kept in memory during streaming before eviction */ +const MAX_STREAMING_EVENTS = 2000; +/** Fraction of events to evict when cap is hit (evict oldest 25%) */ +const EVICTION_FRACTION = 0.25; + interface UseReqTimelineEnhancedOptions { limit?: number; stream?: boolean; @@ -52,7 +61,7 @@ export function useReqTimelineEnhanced( ): UseReqTimelineEnhancedReturn { const eventStore = useEventStore(); const { limit, stream = false, relayFilterMap } = options; - const stableRelayFilterMap = useStableValue(relayFilterMap); + const stableRelayFilterMap = useStableRelayFilterMap(relayFilterMap); // Core state (compatible with original useReqTimeline) const [loading, setLoading] = useState(false); @@ -69,6 +78,21 @@ export function useReqTimelineEnhanced( const queryStartedAt = useRef(Date.now()); const eoseReceivedRef = useRef(false); + // Keep relay filter map in a ref so subscription callbacks always + // read the latest value without requiring subscription teardown + const relayFilterMapRef = useRef(stableRelayFilterMap); + useEffect(() => { + relayFilterMapRef.current = stableRelayFilterMap; + }, [stableRelayFilterMap]); + + // Derive a key that only changes when the SET of relays in the filter map changes, + // not when filter content changes (pubkey redistribution). This prevents subscription + // churn when relay reasoning updates but the relay set stays the same. + const relaySetFromFilterMap = useMemo(() => { + if (!stableRelayFilterMap) return undefined; + return Object.keys(stableRelayFilterMap).sort().join(","); + }, [stableRelayFilterMap]); + // Keep ref in sync with state useEffect(() => { eoseReceivedRef.current = eoseReceived; @@ -176,7 +200,8 @@ export function useReqTimelineEnhanced( const relay = pool.relay(url); // Use per-relay chunked filters if available, otherwise use the full filter - const relayFilters = stableRelayFilterMap?.[url]; + // Read from ref so filter map updates don't require subscription teardown + const relayFilters = relayFilterMapRef.current?.[url]; const filtersForRelay = relayFilters ? relayFilters.map((f) => ({ ...f, limit: limit || f.limit })) : filtersWithLimit; @@ -225,47 +250,59 @@ export function useReqTimelineEnhanced( // Event received - store and track per relay const event = response as NostrEvent & { _relay?: string }; - // Store in EventStore and local map + // Store in EventStore (global) and local map eventStore.add(event); + + // Fix 1a: Skip duplicate events already in our map setEventsMap((prev) => { + if (prev.has(event.id)) return prev; const next = new Map(prev); next.set(event.id, event); + + // Fix 3: Cap events during streaming to prevent unbounded growth + if (stream && next.size > MAX_STREAMING_EVENTS) { + const entries = Array.from(next.entries()); + entries.sort((a, b) => a[1].created_at - b[1].created_at); + const evictCount = Math.floor( + MAX_STREAMING_EVENTS * EVICTION_FRACTION, + ); + for (let i = 0; i < evictCount; i++) { + next.delete(entries[i][0]); + } + } + return next; }); - // Update relay state for this specific relay - // Use url from subscription, not event._relay (which might be wrong) + // Fix 1b + 5: Only update relay state on actual state transitions setRelayStates((prev) => { const state = prev.get(url); - const now = Date.now(); - const next = new Map(prev); - if (!state) { - // Relay not in map - initialize it (defensive) - console.warn( - "REQ Enhanced: Event from unknown relay, initializing", - url, - ); - next.set(url, { - url, - connectionState: "connected", - subscriptionState: "receiving", - eventCount: 1, - firstEventAt: now, - lastEventAt: now, - }); - } else { - // Update existing relay state - next.set(url, { - ...state, - subscriptionState: - state.subscriptionState === "eose" ? "eose" : "receiving", - eventCount: state.eventCount + 1, - firstEventAt: state.firstEventAt ?? now, - lastEventAt: now, - }); + // Fix 5: Don't add unknown relays to the state map + if (!state) return prev; + + const now = Date.now(); + const newSubState = + state.subscriptionState === "eose" ? "eose" : "receiving"; + + // Only create new Map when subscription state actually transitions + // (waiting → receiving). Counter-only updates are applied in-place + // and become visible on the next state transition. + if (state.subscriptionState === newSubState) { + state.eventCount += 1; + state.lastEventAt = now; + return prev; // No re-render for counter-only updates } + // State transition — create new Map + const next = new Map(prev); + next.set(url, { + ...state, + subscriptionState: newSubState, + eventCount: state.eventCount + 1, + firstEventAt: state.firstEventAt ?? now, + lastEventAt: now, + }); return next; }); } else { @@ -307,7 +344,7 @@ export function useReqTimelineEnhanced( id, stableFilters, stableRelays, - stableRelayFilterMap, + relaySetFromFilterMap, limit, stream, eventStore,