From e7f47fa8dbf6cd2073aa7f008b0f511b9d2b59d6 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 11 Aug 2026 15:48:17 -0400 Subject: [PATCH] perf(desktop): bound message-window query cache by an LRU timeline window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every visited channel and thread leaves its query-cache keys in place for gcTime (one hour) after the view unmounts, so a session that roams many channels holds every timeline's window store and flattened render array in RAM at once — a renderer memory growth behind the v0.5.9 scroll/input lag. gcTime bounds how long an inactive query lingers, not how many linger concurrently; this adds the missing concurrency bound. A cache subscription mounted once in CommunityQueryProvider evicts the least-recently-updated unpinned timelines past a fixed cap. Channels and threads are bounded independently so a burst of open threads never evicts channel scrollback. A unit is pinned while any of its queries has a mounted observer or holds an optimistic pending send. Eviction removes both channel keys together and reads back absent, so revisit re-fetches from SQLite and a guarded background write cannot resurrect an evicted key. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/App.tsx | 3 + .../messages/lib/boundMessageWindows.test.mjs | 208 ++++++++++++++++++ .../messages/lib/boundMessageWindows.ts | 128 +++++++++++ .../lib/messageWindowEviction.test.mjs | 89 ++++++++ .../messages/lib/messageWindowEviction.ts | 65 ++++++ .../messages/useBoundedMessageWindows.ts | 51 +++++ 6 files changed, 544 insertions(+) create mode 100644 desktop/src/features/messages/lib/boundMessageWindows.test.mjs create mode 100644 desktop/src/features/messages/lib/boundMessageWindows.ts create mode 100644 desktop/src/features/messages/lib/messageWindowEviction.test.mjs create mode 100644 desktop/src/features/messages/lib/messageWindowEviction.ts create mode 100644 desktop/src/features/messages/useBoundedMessageWindows.ts diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..4850a6934e 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -59,6 +59,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +import { useBoundedMessageWindows } from "@/features/messages/useBoundedMessageWindows"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; @@ -213,6 +214,8 @@ function CommunitySwitchGate() { function CommunityQueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(createBuzzQueryClient); + useBoundedMessageWindows(queryClient); + useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); useEffect(() => { diff --git a/desktop/src/features/messages/lib/boundMessageWindows.test.mjs b/desktop/src/features/messages/lib/boundMessageWindows.test.mjs new file mode 100644 index 0000000000..f39715d026 --- /dev/null +++ b/desktop/src/features/messages/lib/boundMessageWindows.test.mjs @@ -0,0 +1,208 @@ +/** + * Store-level tests for enforceMessageWindowBounds against a REAL QueryClient. + * + * These exercise the collect → fold → select → remove path at the cache + * boundary — the layer above the pure policy unit — asserting: + * - channels and threads are bounded independently at their caps; + * - eviction is least-recently-updated first; + * - evicting a channel removes BOTH its window and messages keys together; + * - an active channel (mounted observer) is never evicted, even when stale; + * - a pending send pins its channel — including the cross-key case where the + * optimistic event lands on a non-visible channel-window `liveOverlay`; + * - an evicted channel reads back absent → the freshness seam re-fetches; + * - a guarded background write cannot resurrect an evicted key (durable + * truncation contract). + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; + +import { + channelMessagesKey, + channelWindowKey, + threadRepliesKey, +} from "./messageQueryKeys.ts"; +import { + emptyChannelWindowStore, + mergeLiveChannelWindowEvent, +} from "./channelWindowStore.ts"; +import { shouldRefreshChannelWindowAfterSubscribe } from "./projectChannelWindow.ts"; +import { + enforceMessageWindowBounds, + MAX_RETAINED_MESSAGE_CHANNELS, + MAX_RETAINED_MESSAGE_THREADS, +} from "./boundMessageWindows.ts"; + +const CHAN_CAP = MAX_RETAINED_MESSAGE_CHANNELS; +const THREAD_CAP = MAX_RETAINED_MESSAGE_THREADS; + +let client; + +/** A relay event; `pending` marks an un-acked optimistic send. */ +function event(id, createdAt, extra = {}) { + return { + id: id.padEnd(64, "0"), + pubkey: "a".repeat(64), + created_at: createdAt, + kind: 9, + tags: [["h", "channel"]], + content: id, + sig: "b".repeat(128), + ...extra, + }; +} + +/** + * Seed a channel's window + messages keys at a given recency. `updatedAt` + * drives `dataUpdatedAt` so the LRU order is deterministic in tests. + */ +function seedChannel(channelId, updatedAt, { pendingSend = false } = {}) { + let store = emptyChannelWindowStore(); + if (pendingSend) { + store = mergeLiveChannelWindowEvent( + store, + event(`pending-${channelId}`, updatedAt, { pending: true }), + ); + } + client.setQueryData(channelWindowKey(channelId), store, { updatedAt }); + client.setQueryData(channelMessagesKey(channelId), [], { updatedAt }); +} + +function seedThread(channelId, rootId, updatedAt) { + client.setQueryData(threadRepliesKey(channelId, rootId), [], { updatedAt }); +} + +/** Mount an observer so the query reports `isActive()` — a pinned view. */ +function mountChannel(channelId) { + const windowObserver = new QueryObserver(client, { + queryKey: channelWindowKey(channelId), + enabled: true, + }); + const messagesObserver = new QueryObserver(client, { + queryKey: channelMessagesKey(channelId), + enabled: true, + }); + const unsubWindow = windowObserver.subscribe(() => {}); + const unsubMessages = messagesObserver.subscribe(() => {}); + return () => { + unsubWindow(); + unsubMessages(); + }; +} + +function hasChannel(channelId) { + return ( + client.getQueryData(channelWindowKey(channelId)) !== undefined || + client.getQueryData(channelMessagesKey(channelId)) !== undefined + ); +} + +describe("enforceMessageWindowBounds", () => { + beforeEach(() => { + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + }); + + it("test_channels_up_to_cap_are_all_retained", () => { + for (let i = 0; i < CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + enforceMessageWindowBounds(client); + for (let i = 0; i < CHAN_CAP; i += 1) { + assert.ok(hasChannel(`chan-${i}`), `chan-${i} must be retained at cap`); + } + }); + + it("test_exceeding_cap_evicts_least_recently_updated_channel", () => { + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + enforceMessageWindowBounds(client); + assert.equal(hasChannel("chan-0"), false, "oldest channel evicted"); + for (let i = 1; i <= CHAN_CAP; i += 1) { + assert.ok(hasChannel(`chan-${i}`), `chan-${i} must survive`); + } + }); + + it("test_evicting_a_channel_removes_both_window_and_messages_keys", () => { + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + enforceMessageWindowBounds(client); + assert.equal( + client.getQueryData(channelWindowKey("chan-0")), + undefined, + "window key gone", + ); + assert.equal( + client.getQueryData(channelMessagesKey("chan-0")), + undefined, + "messages key gone", + ); + }); + + it("test_active_channel_is_never_evicted_even_when_least_recent", () => { + // chan-0 is the oldest (recency 0) but mounted → pinned. One extra channel + // over cap forces an eviction; the next-oldest unpinned (chan-1) goes. + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + const unmount = mountChannel("chan-0"); + enforceMessageWindowBounds(client); + assert.ok(hasChannel("chan-0"), "active channel pinned"); + assert.equal(hasChannel("chan-1"), false, "next-oldest unpinned evicted"); + unmount(); + }); + + it("test_pending_send_pins_its_channel_via_window_overlay", () => { + // chan-0 is oldest and inactive but holds an optimistic send in its window + // overlay (the cross-key send-from-thread seam) → pinned. chan-1 evicted. + for (let i = 0; i <= CHAN_CAP; i += 1) { + seedChannel(`chan-${i}`, i, { pendingSend: i === 0 }); + } + enforceMessageWindowBounds(client); + assert.ok(hasChannel("chan-0"), "channel with pending send pinned"); + assert.equal(hasChannel("chan-1"), false, "next-oldest unpinned evicted"); + }); + + it("test_threads_are_bounded_independently_from_channels", () => { + // Many channels AND many threads over both caps: each bounded to its own + // cap, neither starving the other. + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + for (let i = 0; i <= THREAD_CAP; i += 1) seedThread("c", `root-${i}`, i); + enforceMessageWindowBounds(client); + // Oldest of each evicted; newest survive. + assert.equal(hasChannel("chan-0"), false); + assert.equal( + client.getQueryData(threadRepliesKey("c", "root-0")), + undefined, + "oldest thread evicted", + ); + assert.notEqual( + client.getQueryData(threadRepliesKey("c", `root-${THREAD_CAP}`)), + undefined, + "newest thread survives", + ); + }); + + it("test_evicted_channel_reads_absent_so_revisit_refetches", () => { + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + enforceMessageWindowBounds(client); + // Freshness seam: an evicted channel has no messages state, so a resubscribe + // must refresh (returns true) rather than reading a stale window as fresh. + assert.equal( + shouldRefreshChannelWindowAfterSubscribe(client, "chan-0"), + true, + "evicted channel must refetch on revisit", + ); + }); + + it("test_guarded_background_write_does_not_resurrect_evicted_channel", () => { + for (let i = 0; i <= CHAN_CAP; i += 1) seedChannel(`chan-${i}`, i); + enforceMessageWindowBounds(client); + // The background live path writes messages with a guarded updater that + // no-ops when the key is absent. It must NOT rebuild the evicted key. + client.setQueryData(channelMessagesKey("chan-0"), (current) => + !current ? current : [...current, event("live", 999)], + ); + assert.equal( + client.getQueryData(channelMessagesKey("chan-0")), + undefined, + "guarded write must not resurrect an evicted key", + ); + }); +}); diff --git a/desktop/src/features/messages/lib/boundMessageWindows.ts b/desktop/src/features/messages/lib/boundMessageWindows.ts new file mode 100644 index 0000000000..12c51fb55c --- /dev/null +++ b/desktop/src/features/messages/lib/boundMessageWindows.ts @@ -0,0 +1,128 @@ +import type { Query, QueryClient } from "@tanstack/react-query"; + +import type { ChannelWindowStore } from "./channelWindowStore"; +import type { RelayEvent } from "@/shared/api/types"; +import { + selectMessageWindowEvictionUnits, + type MessageWindowUnit, +} from "./messageWindowEviction"; + +/** + * Maximum number of distinct unpinned channel timelines and thread subtrees + * retained in the query cache. Channels and threads are bounded independently + * so a burst of open threads never evicts channel scrollback and vice versa. + * A revisit re-fetches from the relay (cache miss, not data loss). + */ +export const MAX_RETAINED_MESSAGE_CHANNELS = 12; +export const MAX_RETAINED_MESSAGE_THREADS = 24; + +const CHANNEL_WINDOW = "channel-window"; +const CHANNEL_MESSAGES = "channel-messages"; +const THREAD_REPLIES = "thread-replies"; + +/** A cached message query keyed by its parsed unit id, tagged by kind. */ +type ClassifiedQuery = { + query: Query; + unitId: string; + kind: "channel" | "thread"; +}; + +/** + * Classify a cache query as a channel-timeline key, a thread key, or neither. + * Channel units fold both `channel-window` and `channel-messages` under the + * channel id so they evict together; thread units key on channel + root. + */ +function classifyQuery(query: Query): ClassifiedQuery | null { + const key = query.queryKey; + const head = key[0]; + if ( + (head === CHANNEL_WINDOW || head === CHANNEL_MESSAGES) && + typeof key[1] === "string" + ) { + return { query, unitId: key[1], kind: "channel" }; + } + if ( + head === THREAD_REPLIES && + typeof key[1] === "string" && + typeof key[2] === "string" + ) { + return { query, unitId: `${key[1]}\u0000${key[2]}`, kind: "thread" }; + } + return null; +} + +/** Whether a query's cached data holds an optimistic pending send. */ +function holdsPendingSend(query: Query): boolean { + const data = query.state.data; + if (Array.isArray(data)) { + return (data as RelayEvent[]).some((event) => event.pending); + } + // A channel-window store keeps optimistic sends in its live overlay — the + // seam that catches a send-from-thread landing on a non-visible channel. + const overlay = (data as ChannelWindowStore | undefined)?.liveOverlay; + return Array.isArray(overlay) && overlay.some((event) => event.pending); +} + +/** + * Fold classified queries into eviction units. A unit is pinned when ANY of + * its queries has a mounted observer (the view is on screen — the "none" + * placeholder channel is inactive and so never pins) or holds a pending send. + * Recency is the freshest `dataUpdatedAt` across the unit's queries. + */ +function foldUnits(queries: ClassifiedQuery[]): Map { + const units = new Map(); + for (const { query, unitId } of queries) { + const pinned = query.isActive() || holdsPendingSend(query); + const recency = query.state.dataUpdatedAt; + const existing = units.get(unitId); + if (existing) { + existing.pinned ||= pinned; + existing.recency = Math.max(existing.recency, recency); + } else { + units.set(unitId, { unitId, pinned, recency }); + } + } + return units; +} + +/** + * Enforce the retained-timeline bound on the query cache. Collects every + * channel and thread query, folds them into pinned/recency units, selects the + * least-recently-updated unpinned units beyond the caps, and removes their + * queries. Channels and threads are bounded independently. + * + * Removal (not invalidation) is deliberate: a removed key reads back as absent, + * so `shouldRefreshChannelWindowAfterSubscribe` re-fetches fresh on revisit and + * a guarded background write (`(current) => !current ? current : merge`) is a + * no-op that cannot silently resurrect the evicted key. + */ +export function enforceMessageWindowBounds(queryClient: QueryClient): void { + const channels: ClassifiedQuery[] = []; + const threads: ClassifiedQuery[] = []; + for (const query of queryClient.getQueryCache().getAll()) { + const classified = classifyQuery(query); + if (!classified) continue; + (classified.kind === "channel" ? channels : threads).push(classified); + } + + const evictChannels = new Set( + selectMessageWindowEvictionUnits( + [...foldUnits(channels).values()], + MAX_RETAINED_MESSAGE_CHANNELS, + ), + ); + const evictThreads = new Set( + selectMessageWindowEvictionUnits( + [...foldUnits(threads).values()], + MAX_RETAINED_MESSAGE_THREADS, + ), + ); + if (evictChannels.size === 0 && evictThreads.size === 0) return; + + for (const { query, unitId, kind } of [...channels, ...threads]) { + const evict = kind === "channel" ? evictChannels : evictThreads; + if (evict.has(unitId)) { + queryClient.getQueryCache().remove(query); + } + } +} diff --git a/desktop/src/features/messages/lib/messageWindowEviction.test.mjs b/desktop/src/features/messages/lib/messageWindowEviction.test.mjs new file mode 100644 index 0000000000..38c5894c8c --- /dev/null +++ b/desktop/src/features/messages/lib/messageWindowEviction.test.mjs @@ -0,0 +1,89 @@ +/** + * Unit tests for selectMessageWindowEvictionUnits — the pure LRU-with-pins + * policy that bounds the message query-cache windows. Exercises the selection + * logic directly, with no React/React-Query runtime. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { selectMessageWindowEvictionUnits } from "./messageWindowEviction.ts"; + +/** Build one eviction unit. */ +function unit(unitId, recency, pinned = false) { + return { unitId, recency, pinned }; +} + +describe("selectMessageWindowEvictionUnits", () => { + it("test_empty_units_returns_no_eviction", () => { + assert.deepEqual(selectMessageWindowEvictionUnits([], 8), []); + }); + + it("test_count_below_cap_returns_no_eviction", () => { + const units = [unit("a", 1), unit("b", 2), unit("c", 3)]; + assert.deepEqual(selectMessageWindowEvictionUnits(units, 8), []); + }); + + it("test_count_exactly_at_cap_returns_no_eviction", () => { + const units = Array.from({ length: 8 }, (_, i) => unit(`u-${i}`, i)); + assert.deepEqual(selectMessageWindowEvictionUnits(units, 8), []); + }); + + it("test_over_cap_evicts_least_recently_updated_units", () => { + // 10 units, cap 8 → evict the 2 lowest-recency (u-0, u-1). + const units = Array.from({ length: 10 }, (_, i) => unit(`u-${i}`, i)); + const evicted = selectMessageWindowEvictionUnits(units, 8); + assert.deepEqual(evicted.sort(), ["u-0", "u-1"].sort()); + }); + + it("test_pinned_unit_is_never_evicted_even_when_least_recent", () => { + // u-pin is least recent but pinned → survives; u-1 evicted instead. + const units = [ + unit("u-pin", 0, true), + unit("u-1", 1), + unit("u-2", 2), + unit("u-3", 3), + ]; + const evicted = selectMessageWindowEvictionUnits(units, 3); + assert.deepEqual(evicted, ["u-1"]); + }); + + it("test_pins_exceeding_cap_evict_all_unpinned_but_keep_all_pinned", () => { + // 3 pinned + 2 unpinned, cap 2. Pins alone exceed cap → unpinned budget 0. + const units = [ + unit("pin-1", 10, true), + unit("pin-2", 11, true), + unit("pin-3", 12, true), + unit("free-1", 1), + unit("free-2", 2), + ]; + const evicted = selectMessageWindowEvictionUnits(units, 2); + assert.deepEqual(evicted.sort(), ["free-1", "free-2"].sort()); + }); + + it("test_pinned_units_count_against_cap_for_unpinned_budget", () => { + // 1 pinned + 3 unpinned, cap 2 → unpinned budget = 1. Keep newest unpinned. + const units = [ + unit("pin-1", 5, true), + unit("free-1", 1), + unit("free-2", 2), + unit("free-3", 3), + ]; + const evicted = selectMessageWindowEvictionUnits(units, 2); + assert.deepEqual(evicted.sort(), ["free-1", "free-2"].sort()); + }); + + it("test_tie_broken_deterministically_by_unit_id", () => { + // unit-a and unit-b share recency 1 at the boundary; keep-sort breaks ties + // by ascending unitId, so unit-a is retained and unit-b evicted. + const units = [unit("unit-b", 1), unit("unit-a", 1), unit("unit-keep", 9)]; + const evicted = selectMessageWindowEvictionUnits(units, 2); + assert.deepEqual(evicted, ["unit-b"]); + }); + + it("test_zero_cap_evicts_all_unpinned_units", () => { + const units = [unit("a", 1), unit("b", 2), unit("pin", 3, true)]; + const evicted = selectMessageWindowEvictionUnits(units, 0); + assert.deepEqual(evicted.sort(), ["a", "b"].sort()); + }); +}); diff --git a/desktop/src/features/messages/lib/messageWindowEviction.ts b/desktop/src/features/messages/lib/messageWindowEviction.ts new file mode 100644 index 0000000000..47b44c4f72 --- /dev/null +++ b/desktop/src/features/messages/lib/messageWindowEviction.ts @@ -0,0 +1,65 @@ +/** + * Pure LRU-with-pins eviction policy for the message query-cache windows. + * + * The channel timeline lives in the React Query cache as two keys per channel + * (`channel-window` holding the authoritative window store and + * `channel-messages` holding the flattened render array) plus one + * `thread-replies` key per open thread root. Every visited channel or thread + * leaves its keys in the cache for `gcTime` (one hour) after the view unmounts, + * so a session that visits many channels accumulates every timeline's window + * store and flattened array at once — a renderer memory leak behind the v0.5.9 + * lag. React Query's `gcTime` bounds *how long* an inactive query lingers, not + * *how many* linger concurrently; this policy adds the missing concurrency + * bound. + * + * Eviction is at unit granularity — a channel unit owns both of its keys, a + * thread unit owns its single key — because the two channel keys are one + * timeline: dropping the window store while keeping the flattened array leaves + * a render array that live merges keep re-growing against no backing store. + * + * Pins are inviolable: a unit is never evicted while any of its queries has a + * mounted observer (the view is on screen) or holds an optimistic pending send + * (dropping it would lose an in-flight message the relay has not yet echoed). + * The cap is enforced only against unpinned units, even when pins alone exceed + * it. + * + * Pure and React/React-Query-free so the selection logic is unit-testable in + * isolation, mirroring the Target 1 `observerArchiveEviction` extraction. + */ + +export interface MessageWindowUnit { + /** Stable identifier for the eviction unit (channel id, or channel+root). */ + unitId: string; + /** Most recent `dataUpdatedAt` across the unit's queries; higher = fresher. */ + recency: number; + /** True when the unit must be retained: mounted observer or pending send. */ + pinned: boolean; +} + +/** + * Select the unit ids to evict so that no more than `cap` distinct unpinned + * units are retained. Pinned units never appear in the result. + * + * @param units One entry per eviction unit (channel or thread). + * @param cap Maximum distinct unpinned units to keep. + */ +export function selectMessageWindowEvictionUnits( + units: readonly MessageWindowUnit[], + cap: number, +): string[] { + const unpinned = units.filter((unit) => !unit.pinned); + const pinnedCount = units.length - unpinned.length; + const unpinnedBudget = Math.max(0, cap - pinnedCount); + if (unpinned.length <= unpinnedBudget) { + return []; + } + + // Keep the most-recent `unpinnedBudget` units; evict the rest. Sort by + // recency descending, breaking ties by unitId so selection is deterministic. + const evictable = [...unpinned].sort( + (a, b) => + b.recency - a.recency || + (a.unitId < b.unitId ? -1 : a.unitId > b.unitId ? 1 : 0), + ); + return evictable.slice(unpinnedBudget).map((unit) => unit.unitId); +} diff --git a/desktop/src/features/messages/useBoundedMessageWindows.ts b/desktop/src/features/messages/useBoundedMessageWindows.ts new file mode 100644 index 0000000000..816d905609 --- /dev/null +++ b/desktop/src/features/messages/useBoundedMessageWindows.ts @@ -0,0 +1,51 @@ +import type { QueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { enforceMessageWindowBounds } from "./lib/boundMessageWindows"; + +const BOUNDED_MESSAGE_KEYS = new Set([ + "channel-window", + "channel-messages", + "thread-replies", +]); + +/** + * Bound the number of channel timelines and thread subtrees retained in the + * query cache. Every visited channel/thread lingers for `gcTime` (one hour) + * after its view unmounts, so a session that roams many channels accumulates + * every timeline's window store and flattened array at once — the renderer + * memory leak behind the v0.5.9 lag. This mounts one cache subscription per + * QueryClient that evicts the least-recently-updated unpinned timelines past a + * fixed cap (see `enforceMessageWindowBounds`). + * + * Mounted once inside `CommunityQueryProvider`, so the bound applies to every + * window that renders it — the main app, the huddle room, and the companion — + * each on its own client. + * + * A new message key entering the cache is the only event that can push the + * count over its cap, so the sweep is triggered on `added` for a bounded key + * and coalesced to one run per microtask (a channel open adds two keys at + * once). Removals emit `removed`, never `added`, so eviction cannot re-trigger + * itself. The sweep runs synchronously in a single tick — collect then remove + * — so a view mounting mid-sweep cannot be misread as inactive. + */ +export function useBoundedMessageWindows(queryClient: QueryClient): void { + React.useEffect(() => { + let scheduled = false; + const sweep = () => { + scheduled = false; + enforceMessageWindowBounds(queryClient); + }; + return queryClient.getQueryCache().subscribe((event) => { + if ( + event.type !== "added" || + !BOUNDED_MESSAGE_KEYS.has(event.query.queryKey[0]) + ) { + return; + } + if (scheduled) return; + scheduled = true; + queueMicrotask(sweep); + }); + }, [queryClient]); +}