diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index b1aaa497..99e58b91 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -242,15 +242,20 @@ export default function FullTextSearchByRelay({ relayRows.length > 0 && relayRows.every((r) => r.phase === 'done' || r.phase === 'error') useEffect(() => { + const abort = new AbortController() const myRun = ++runGeneration.current + const cleanupInvalidatePreviousRun = () => { + runGeneration.current += 1 + } + const dispose = () => { + abort.abort() + cleanupInvalidatePreviousRun() + } + if (!q || normalizedRelays.length === 0) { setRelayRows([]) setMergedHits([]) - return - } - - const cleanupInvalidatePreviousRun = () => { - runGeneration.current += 1 + return dispose } const filter: Filter = { @@ -311,7 +316,7 @@ export default function FullTextSearchByRelay({ const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( relayUrl, filter, - { globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS } + { globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS, signal: abort.signal } ) if (myRun !== runGeneration.current) return @@ -375,7 +380,7 @@ export default function FullTextSearchByRelay({ } })() - return cleanupInvalidatePreviousRun + return dispose }, [q, normalizedRelays, kinds]) if (!q) { diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index cafe7b01..4f5db6b3 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -7,8 +7,9 @@ import { ProfileListBySearch } from '../ProfileListBySearch' import Relay from '../Relay' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import client from '@/services/client.service' import { normalizeUrl } from '@/lib/url' -import { useMemo } from 'react' +import { useLayoutEffect, useMemo } from 'react' function relayDedupeKey(url: string): string { return (normalizeUrl(url) || url.trim()).toLowerCase() @@ -18,6 +19,13 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa const { pubkey, relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + /** Before child effects (e.g. NIP-50) open REQs, tear down idle feed / prefetch queries so search gets the pool. */ + useLayoutEffect(() => { + if (!searchParams) return + if (searchParams.type === 'relay') return + client.interruptBackgroundQueries() + }, [searchParams?.type, searchParams?.search, searchParams?.input]) + /** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */ const searchableUrls = useMemo( () => @@ -34,7 +42,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa // User stack + defaults (hashtag search uses the non-searchable slice as a second shard) const combinedRelays = useMemo(() => { - let relays: string[] = [] + const relays: string[] = [] if (relayList) { relays.push(...(relayList.read || []), ...(relayList.write || [])) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index f6d38189..594e4299 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1537,6 +1537,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } + client.interruptBackgroundQueries() noteStatsService.beginPublishPriority() try { logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) @@ -1658,6 +1659,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const deletionRequest = await signEvent(createDeletionRequestDraftEvent(targetEvent)) + client.interruptBackgroundQueries() + // Privacy: Only use user's own relays, never connect to "seen on" relays const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null) const relays = await client.determineTargetRelays(targetEvent, { diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index e75313b9..c9c4e95b 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -222,6 +222,16 @@ export interface QueryOptions { firstRelayResultGraceMs?: number | false /** Label for {@link RelaySubscribeOpBatch} when this query opens REQs. */ relayOpSource?: string + /** + * When aborted (e.g. React effect cleanup / HMR), closes WS + HTTP index work promptly instead of waiting for + * {@link globalTimeout}. Prevents overlapping NIP-50 shards from stacking until the tab OOMs. + */ + signal?: AbortSignal + /** + * When true, this query ignores {@link QueryService.interruptBackgroundQueries} (e.g. NIP-50 shard with its own + * AbortController, or other foreground work that must not be tied to the global background token). + */ + foreground?: boolean } export interface SubscribeCallbacks { @@ -255,6 +265,21 @@ export class QueryService { private globalRelayConnectionSlotsInUse = 0 private globalRelayConnectionWaitQueue: Array<() => void> = [] + /** + * Aborted whenever {@link interruptBackgroundQueries} runs. Default {@link query} runs listen until close so + * feed / prefetch / replaceable fetches yield to search and publish. + */ + private backgroundInterruptController = new AbortController() + + /** + * Best-effort: abort in-flight {@link query} calls that did not pass `foreground: true`, then reset the token so + * new background work uses a fresh signal. + */ + interruptBackgroundQueries(): void { + this.backgroundInterruptController.abort() + this.backgroundInterruptController = new AbortController() + } + async acquireGlobalRelayConnectionSlot(): Promise { if (this.globalRelayConnectionSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) { this.globalRelayConnectionSlotsInUse++ @@ -359,6 +384,7 @@ export class QueryService { ): Promise { const sanitizedFilters = sanitizeFiltersBeforeReq(filter) if (sanitizedFilters.length === 0) return [] + if (options?.signal?.aborted) return [] const maxFilters = RELAY_REQ_MAX_FILTERS_PER_MESSAGE if (sanitizedFilters.length > maxFilters) { @@ -418,10 +444,12 @@ export class QueryService { const reqId = ++queryReqSeq const source = options?.relayOpSource ?? 'QueryService.query' const inputRelaysOrdered = Array.from(new Set(urls.map((u) => normalizeUrl(u) || u).filter(Boolean))) - const wsRelayCandidates = Array.from(new Set(wsQueryUrls.map((u) => normalizeUrl(u) || u).filter(Boolean))) + + const foreground = options?.foreground === true return await new Promise((resolve) => { const events: NEvent[] = [] + const cancelAbortRegistrations: Array<() => void> = [] const abortHttp = new AbortController() let resolveTimeout: ReturnType | null = null let firstResultGraceTimeoutId: ReturnType | null = null @@ -523,6 +551,10 @@ export class QueryService { */ const finalizeOnce = () => { if (resolved) return + for (const detach of cancelAbortRegistrations) { + detach() + } + cancelAbortRegistrations.length = 0 resolved = true if (resolveTimeout) clearTimeout(resolveTimeout) if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId) @@ -664,6 +696,29 @@ export class QueryService { } } + const onAbortQuery = () => { + sub.close() + resolveWithEvents() + } + const registerQueryAbort = (sig: AbortSignal) => { + if (sig.aborted) { + queueMicrotask(() => { + onAbortQuery() + }) + return + } + sig.addEventListener('abort', onAbortQuery, { once: true }) + cancelAbortRegistrations.push(() => { + sig.removeEventListener('abort', onAbortQuery) + }) + } + if (!foreground) { + registerQueryAbort(this.backgroundInterruptController.signal) + } + if (options?.signal) { + registerQueryAbort(options.signal) + } + globalTimeoutId = setTimeout(() => resolveWithEvents(), globalTimeout) }) } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 1cb9f9bd..c68625f8 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -185,8 +185,6 @@ export class ReplaceableEventService { d?: string, containingEventRelays: string[] = [] ): Promise { - const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}` - try { if (kind === kinds.Metadata && !d) { const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 60b0048d..fb19920e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3136,6 +3136,11 @@ class ClientService extends EventTarget { set.add(relay) } + /** Yield relay pool / HTTP index capacity to search or publish by aborting default {@link QueryService.query} work. */ + interruptBackgroundQueries(): void { + this.queryService.interruptBackgroundQueries() + } + // Delegate to QueryService private async query( urls: string[], @@ -3228,7 +3233,7 @@ class ClientService extends EventTarget { async fetchEventsFromSingleRelay( url: string, filter: Filter | Filter[], - options?: { globalTimeout?: number } + options?: { globalTimeout?: number; signal?: AbortSignal } ): Promise<{ events: NEvent[]; connectionError?: string }> { const normalized = normalizeAnyRelayUrl(url) || url if (!normalized) { @@ -3236,7 +3241,9 @@ class ClientService extends EventTarget { } const queryOpts = { globalTimeout: options?.globalTimeout ?? 25_000, - relayOpSource: 'fetchEventsFromSingleRelay' as const + relayOpSource: 'fetchEventsFromSingleRelay' as const, + foreground: true as const, + ...(options?.signal ? { signal: options.signal } : {}) } if (isHttpRelayUrl(normalized)) {