From d12c9a352fbfd6fe49162020f24699e3d88f957b Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 20 Aug 2026 21:52:18 -0400 Subject: [PATCH 1/5] perf(desktop): de-block thread aux from first paint, page 500, cache reopens A 300-reply thread cold-open paid four serial relay legs before anything painted: two content pages (limit 200) followed by two aux waves for edits/deletions/reactions over all reply ids. staleTime:0 re-ran the whole pipeline on every reopen. Raise THREAD_PAGE_LIMIT to the server-clamped 500 so a <=500-reply thread fetches its content in one page. Resolve loadThreadReplies with content as soon as the page loop completes and hydrate aux into the same thread-replies cache via a functional setQueryData merge, off the critical path -- the exact pattern the channel timeline already ships. Set a 30s staleTime so reopening a recently-loaded thread is a cache hit; the live subscription keeps the subscribed channel's cache fresh while the bound guarantees an unsubscribed thread refetches to pick up edits/deletions it missed. The merge folds aux over whatever content is current, so a live append that lands mid-flight is preserved and a message a later refetch dropped is never resurrected. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../messages/useThreadReplies.test.mjs | 99 ++++++++++++++++++- .../src/features/messages/useThreadReplies.ts | 95 +++++++++++++++--- 2 files changed, 177 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 48896c5c517..9f07ca4944b 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -1,10 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { collectThreadAuxMessageIds } from "./useThreadReplies.ts"; +import { + THREAD_PAGE_LIMIT, + THREAD_REPLIES_STALE_TIME_MS, + backfillThreadAux, + collectThreadAuxMessageIds, +} from "./useThreadReplies.ts"; +import { threadRepliesKey } from "./lib/messageQueryKeys.ts"; const ROOT_ID = "1".repeat(64); const REPLY_ID = "2".repeat(64); +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; function reply(id = REPLY_ID) { return { @@ -18,6 +25,24 @@ function reply(id = REPLY_ID) { }; } +function makeQueryClientStub(initialEvents = []) { + const store = new Map([ + [JSON.stringify(threadRepliesKey(CHANNEL_ID, ROOT_ID)), initialEvents], + ]); + return { + getQueryData(key) { + return store.get(JSON.stringify(key)); + }, + setQueryData(key, updater) { + const k = JSON.stringify(key); + const next = + typeof updater === "function" ? updater(store.get(k) ?? []) : updater; + store.set(k, next); + return next; + }, + }; +} + test("thread aux hydration includes the root when there are no replies", () => { assert.deepEqual(collectThreadAuxMessageIds(ROOT_ID, []), [ROOT_ID]); }); @@ -28,3 +53,75 @@ test("thread aux hydration includes and deduplicates root and reply ids", () => [ROOT_ID, REPLY_ID], ); }); + +test("page limit uses the server-clamped 500 maximum and staleTime is bounded", () => { + assert.equal(THREAD_PAGE_LIMIT, 500); + assert.ok( + THREAD_REPLIES_STALE_TIME_MS > 0 && + Number.isFinite(THREAD_REPLIES_STALE_TIME_MS), + "staleTime must be a finite positive bound so an unsubscribed thread refetches", + ); +}); + +test("backfillThreadAux merges structural aux and reactions into the content cache", async () => { + const content = reply(); + const client = makeQueryClientStub([content]); + const editEvent = { ...reply("3".repeat(64)), kind: 40003 }; + const reactionEvent = { ...reply("4".repeat(64)), kind: 7 }; + + await backfillThreadAux(client, CHANNEL_ID, ROOT_ID, [content], { + fetchStructuralAux: async () => [editEvent], + fetchReactions: async () => [reactionEvent], + }); + + const cached = client.getQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID)); + assert.deepEqual( + cached.map((event) => event.id).sort(), + [content.id, editEvent.id, reactionEvent.id].sort(), + ); +}); + +test("backfillThreadAux degrades to bare replies when both aux fetches fail", async () => { + const content = reply(); + const client = makeQueryClientStub([content]); + + await backfillThreadAux(client, CHANNEL_ID, ROOT_ID, [content], { + fetchStructuralAux: async () => { + throw new Error("structural aux down"); + }, + fetchReactions: async () => { + throw new Error("reactions down"); + }, + }); + + const cached = client.getQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID)); + assert.deepEqual(cached, [content]); +}); + +test("backfillThreadAux merges over the current cache, not the fetch-time replies", async () => { + // A live WS append writes a new reply into the cache while aux is in flight; + // the functional-updater merge must fold aux over that newer cache and keep + // the appended reply rather than overwriting it with the stale snapshot. + const original = reply(); + const client = makeQueryClientStub([original]); + const liveReply = reply("5".repeat(64)); + const reactionEvent = { ...reply("4".repeat(64)), kind: 7 }; + + await backfillThreadAux(client, CHANNEL_ID, ROOT_ID, [original], { + fetchStructuralAux: async () => [], + fetchReactions: async () => { + // Simulate the live subscription appending a reply mid-flight. + client.setQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID), (current) => [ + ...current, + liveReply, + ]); + return [reactionEvent]; + }, + }); + + const cached = client.getQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID)); + assert.deepEqual( + cached.map((event) => event.id).sort(), + [original.id, liveReply.id, reactionEvent.id].sort(), + ); +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index bb1c2909b68..0502bd9c83d 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -18,9 +18,23 @@ import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters" import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; -const THREAD_PAGE_LIMIT = 200; +// The bridge clamps a thread page to BRIDGE_THREAD_MAX_LIMIT (500) and the +// Tauri command caps it with `.min(500)`, so 500 is the largest page the server +// will serve — a ≤500-reply thread cold-opens in a single content round trip. +export const THREAD_PAGE_LIMIT = 500; const MAX_THREAD_PAGES = 500; +// Reopening a recently-loaded thread within this window is a cache hit rather +// than a full refetch. While the panel's channel is active the live WS +// subscription writes every new content and aux event into the thread-replies +// key (`hooks.ts` `appendMessage`), so a warm cache stays current on its own. +// The bound exists because that self-healing only covers the *subscribed* +// channel: edits/deletions/reactions that arrive for a thread whose channel is +// not currently subscribed never reach the cache, so a finite staleTime +// guarantees the next mount refetches and corrects them. 30s is short enough to +// self-heal quickly yet long enough to collapse the open/close/reopen storm. +export const THREAD_REPLIES_STALE_TIME_MS = 30_000; + /** * Append the structural aux closure (edits/deletions) for the fetched replies. * The server thread-subtree query resolves deletions itself but omits @@ -54,25 +68,70 @@ export function collectThreadAuxMessageIds( ]; } -async function withThreadAux( +/** + * Aux fetchers for {@link backfillThreadAux}, injectable so the merge/degrade + * behavior is unit-testable without a relay. Defaults hit the live relay. + */ +export type ThreadAuxFetchDeps = { + fetchStructuralAux: ( + channelId: string, + messageIds: string[], + ) => Promise; + fetchReactions: ( + channelId: string, + messageIds: string[], + ) => Promise; +}; + +const defaultThreadAuxDeps: ThreadAuxFetchDeps = { + fetchStructuralAux: fetchStructuralAuxForMessages, + fetchReactions: (channelId, messageIds) => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelReactionAuxFilter, + ), +}; + +/** + * Hydrate the structural aux closure (edits/deletions) and reactions for a set + * of replies into the thread-replies cache, mirroring the channel timeline's + * post-history backfill (`backfillAuxForMessages`). Kept off the first-paint + * critical path: `loadThreadReplies` resolves with bare content and fires this + * without awaiting, so an edited reply may briefly render its original text + * until the merge lands (the same accepted tradeoff the timeline ships). + * + * Both branches are best-effort — a failing aux fetch degrades to no adornment + * rather than corrupting the cache. The merge writes through a functional + * updater keyed on the thread's `(channelId, rootId)`, so it can only touch + * that thread's cache and never resurrects content: dedupe-by-id folds the aux + * into whatever content is current, and aux referencing a message a later + * refetch dropped simply renders against nothing. + */ +export async function backfillThreadAux( + queryClient: QueryClient, channelId: string, - threadRootId: string, + rootId: string, replies: RelayEvent[], -): Promise { - const messageIds = collectThreadAuxMessageIds(threadRootId, replies); + deps: ThreadAuxFetchDeps = defaultThreadAuxDeps, +): Promise { + const messageIds = collectThreadAuxMessageIds(rootId, replies); const [structuralAux, reactions] = await Promise.all([ fetchThreadAuxBestEffort("structural aux", channelId, () => - fetchStructuralAuxForMessages(channelId, messageIds), + deps.fetchStructuralAux(channelId, messageIds), ), fetchThreadAuxBestEffort("reactions", channelId, () => - relayClient.fetchAuxEventsByReference( - channelId, - messageIds, - buildChannelReactionAuxFilter, - ), + deps.fetchReactions(channelId, messageIds), ), ]); - return sortMessages([...replies, ...structuralAux, ...reactions]); + const auxEvents = [...structuralAux, ...reactions]; + if (auxEvents.length === 0) { + return; + } + queryClient.setQueryData( + threadRepliesKey(channelId, rootId), + (current = []) => sortMessages([...current, ...auxEvents]), + ); } async function loadThreadReplies( @@ -92,12 +151,16 @@ async function loadThreadReplies( }); replies.push(...response.events); if (!response.nextCursor) { - const fetched = await withThreadAux(channelId, rootId, replies); + // Resolve with content now; hydrate aux into the cache off the critical + // path. The aux fetch is a relay round trip, so React Query commits this + // returned content before `backfillThreadAux`'s merge runs — the merge + // then folds aux over the committed rows via `setQueryData`. + void backfillThreadAux(queryClient, channelId, rootId, replies); const current = queryClient.getQueryData(queryKey) ?? []; const receivedInFlight = current.filter( (event) => !idsAtStart.has(event.id), ); - return sortMessages([...fetched, ...receivedInFlight]); + return sortMessages([...replies, ...receivedInFlight]); } cursor = response.nextCursor; } @@ -123,7 +186,7 @@ export function useThreadReplies( if (!activeChannel || !openThreadRootId) return []; return loadThreadReplies(queryClient, activeChannel.id, openThreadRootId); }, - staleTime: 0, + staleTime: THREAD_REPLIES_STALE_TIME_MS, gcTime: 60 * 60 * 1_000, }); } @@ -145,7 +208,7 @@ export function useThreadRepliesForRoots( queryKey: threadRepliesKey(channelId, rootId), enabled: activeChannel !== null && activeChannel.channelType !== "forum", queryFn: () => loadThreadReplies(queryClient, channelId, rootId), - staleTime: 0, + staleTime: THREAD_REPLIES_STALE_TIME_MS, gcTime: 60 * 60 * 1_000, })), combine: (results) => ({ From 0981d68344769e53aae54480fa78d1456dfd89d4 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 20 Aug 2026 22:04:20 -0400 Subject: [PATCH 2/5] test(desktop): pin thread first-paint de-block seam The suite exercised backfillThreadAux() in isolation but never ran the loader through the de-block seam, so it stayed green if the fire-and-forget dispatch regressed to an await (recreating the first-paint latency this change removes) or was dropped entirely. Make loadThreadReplies injectable at the relay boundary and add behavioral tests proving content resolves while aux is pending, aux merges into the thread cache after content resolves, and aux rejection leaves the content query successful. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../messages/useThreadReplies.test.mjs | 111 ++++++++++++++++++ .../src/features/messages/useThreadReplies.ts | 29 ++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 9f07ca4944b..502f51016e5 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -6,6 +6,7 @@ import { THREAD_REPLIES_STALE_TIME_MS, backfillThreadAux, collectThreadAuxMessageIds, + loadThreadReplies, } from "./useThreadReplies.ts"; import { threadRepliesKey } from "./lib/messageQueryKeys.ts"; @@ -125,3 +126,113 @@ test("backfillThreadAux merges over the current cache, not the fetch-time replie [original.id, liveReply.id, reactionEvent.id].sort(), ); }); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function singlePage(events) { + return async () => ({ events, nextCursor: null }); +} + +test("loadThreadReplies resolves with content while aux is still pending", async () => { + const content = reply(); + const client = makeQueryClientStub([]); + const auxGate = deferred(); + let auxStarted = false; + + const result = await loadThreadReplies(client, CHANNEL_ID, ROOT_ID, { + fetchPage: singlePage([content]), + auxDeps: { + fetchStructuralAux: async () => { + auxStarted = true; + await auxGate.promise; + return []; + }, + fetchReactions: async () => { + await auxGate.promise; + return []; + }, + }, + }); + + // Content is returned before the (still-pending) aux fetch settles: the + // headline first-paint guarantee. If line resolution regressed to + // `await backfillThreadAux(...)`, this await would hang until auxGate resolves. + assert.ok(auxStarted, "aux dispatch should have been fired"); + assert.deepEqual( + result.map((event) => event.id), + [content.id], + ); + auxGate.resolve(); +}); + +test("loadThreadReplies merges aux into the thread cache after content resolves", async () => { + const content = reply(); + const client = makeQueryClientStub([]); + const editEvent = { ...reply("3".repeat(64)), kind: 40003 }; + const auxGate = deferred(); + + const result = await loadThreadReplies(client, CHANNEL_ID, ROOT_ID, { + fetchPage: singlePage([content]), + auxDeps: { + fetchStructuralAux: async () => { + await auxGate.promise; + return [editEvent]; + }, + fetchReactions: async () => { + await auxGate.promise; + return []; + }, + }, + }); + + // At resolve time the cache is untouched by aux (content only). + assert.deepEqual( + result.map((event) => event.id), + [content.id], + ); + + // Mirror React Query committing the resolved queryFn value before aux lands. + client.setQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID), result); + auxGate.resolve(); + // Let the fire-and-forget merge microtasks settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const cached = client.getQueryData(threadRepliesKey(CHANNEL_ID, ROOT_ID)); + assert.deepEqual( + cached.map((event) => event.id).sort(), + [content.id, editEvent.id].sort(), + ); +}); + +test("loadThreadReplies still resolves content when aux fetches reject", async () => { + const content = reply(); + const client = makeQueryClientStub([]); + + const result = await loadThreadReplies(client, CHANNEL_ID, ROOT_ID, { + fetchPage: singlePage([content]), + auxDeps: { + fetchStructuralAux: async () => { + throw new Error("structural aux down"); + }, + fetchReactions: async () => { + throw new Error("reactions down"); + }, + }, + }); + + // Aux rejection is best-effort: the content query succeeds and never sees + // the error (which would collide with the thread-load error surface). + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + result.map((event) => event.id), + [content.id], + ); +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 0502bd9c83d..452c27f8c42 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -134,10 +134,27 @@ export async function backfillThreadAux( ); } -async function loadThreadReplies( +/** + * Dependencies for {@link loadThreadReplies}, injectable so the first-paint + * seam (content resolves before aux settles; aux later merges into the same + * cache key) is unit-testable while stubbing only the relay boundary. Defaults + * hit the live relay. + */ +export type LoadThreadRepliesDeps = { + fetchPage: typeof getThreadReplies; + auxDeps: ThreadAuxFetchDeps; +}; + +const defaultLoadThreadRepliesDeps: LoadThreadRepliesDeps = { + fetchPage: getThreadReplies, + auxDeps: defaultThreadAuxDeps, +}; + +export async function loadThreadReplies( queryClient: QueryClient, channelId: string, rootId: string, + deps: LoadThreadRepliesDeps = defaultLoadThreadRepliesDeps, ): Promise { const queryKey = threadRepliesKey(channelId, rootId); const cacheAtStart = queryClient.getQueryData(queryKey) ?? []; @@ -145,7 +162,7 @@ async function loadThreadReplies( const replies: RelayEvent[] = []; let cursor: ThreadCursor | null = null; for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { - const response = await getThreadReplies(rootId, channelId, { + const response = await deps.fetchPage(rootId, channelId, { limit: THREAD_PAGE_LIMIT, cursor, }); @@ -155,7 +172,13 @@ async function loadThreadReplies( // path. The aux fetch is a relay round trip, so React Query commits this // returned content before `backfillThreadAux`'s merge runs — the merge // then folds aux over the committed rows via `setQueryData`. - void backfillThreadAux(queryClient, channelId, rootId, replies); + void backfillThreadAux( + queryClient, + channelId, + rootId, + replies, + deps.auxDeps, + ); const current = queryClient.getQueryData(queryKey) ?? []; const receivedInFlight = current.filter( (event) => !idsAtStart.has(event.id), From beb5b5348b16eda96284c7f06bda2303e8019729 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 10:47:45 -0400 Subject: [PATCH 3/5] fix(desktop): refetch thread replies on channel resubscribe The 30s staleTime added for the reopen-storm win made an inactive channel's thread-replies cache authoritative on reopen. Switching channels disposes that channel's live subscription, so replies emitted while it is inactive never reach the cache; reopening a thread within the window rendered stale topology and unread state (unread divider, subtree badges, read-clear all computed against pre-switch data). Deterministically broke five thread-unread smoke cases. Invalidate the channel's thread-replies queries when its live subscription (re)establishes, mirroring the channel window's existing resubscribe refresh. This keeps the warm same-channel reopen win (that cache stays subscribed and never re-establishes) while a return-after-switch refetches the missed events. Cached rows still paint immediately via stale-while-revalidate. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/messages/hooks.ts | 19 +++ .../messages/useChannelSubscription.test.mjs | 161 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 desktop/src/features/messages/useChannelSubscription.test.mjs diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf8..fda155d455f 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -294,6 +294,23 @@ export function useChannelSubscription(channel: Channel | null) { await refreshChannelWindowMessages(queryClient, channelId); }); + // Drop the freshness of this channel's thread-replies caches so the next + // thread open refetches. The 30s `staleTime` keeps a warm cache authoritative + // on reopen, but that only stays correct while the channel's live + // subscription is feeding it appends. When the channel goes inactive the + // subscription is disposed, so replies emitted meanwhile never reach the + // cache — reopening within the window would render stale topology and unread + // state. Invalidating on (re)subscribe closes that gap exactly as + // `refreshNewestWindow` does for the channel window: mirrors "freshness alone + // is not a proof that no events landed while we were away." Cached rows still + // paint immediately (stale-while-revalidate); the refetch reconciles them. + const invalidateThreadReplies = useEffectEvent(() => { + if (!channelId) return; + void queryClient.invalidateQueries({ + queryKey: ["thread-replies", channelId], + }); + }); + const appendMessage = useEffectEvent((event: RelayEvent) => { if (!channelId) return; if (event.kind === KIND_CHANNEL_THREAD_SUMMARY) { @@ -382,6 +399,7 @@ export function useChannelSubscription(channel: Channel | null) { let isDisposed = false; let cleanup: (() => Promise) | undefined; const disposeReconnectListener = relayClient.subscribeToReconnects(() => { + invalidateThreadReplies(); void refreshNewestWindow().catch((error) => { if (!isDisposed) { console.error( @@ -410,6 +428,7 @@ export function useChannelSubscription(channel: Channel | null) { // between the last page snapshot and subscription establishment. Always // refresh after the subscription is active; freshness alone is not a // proof that no relay events landed in that interval. + invalidateThreadReplies(); void refreshNewestWindow().catch((error) => { if (!isDisposed) { console.error( diff --git a/desktop/src/features/messages/useChannelSubscription.test.mjs b/desktop/src/features/messages/useChannelSubscription.test.mjs new file mode 100644 index 00000000000..f05a6a6a990 --- /dev/null +++ b/desktop/src/features/messages/useChannelSubscription.test.mjs @@ -0,0 +1,161 @@ +/** + * Seam test for the thread-replies freshness gap that `useChannelSubscription` + * closes on (re)subscribe. + * + * The thread-replies query carries a 30s `staleTime` so reopening a thread + * without leaving the channel is a warm cache hit rather than a refetch storm. + * That is only correct while the channel's live subscription is feeding appends + * into the cache. When the user switches channels the subscription is disposed, + * so replies emitted meanwhile never reach the cache -- reopening within the + * window would render stale topology and unread state (the five failing + * `thread-unread.spec.ts` cases). `useChannelSubscription` invalidates the + * channel's `["thread-replies", channelId]` queries once the subscription + * establishes, mirroring the channel window's "freshness alone is not a proof" + * resubscribe refresh, so the next open refetches. + * + * This mounts the real hook against a stubbed relay client and asserts a warm + * thread-replies cache is marked stale after the subscription resolves, and + * again on a reconnect. Removing either `invalidateThreadReplies()` call fails + * the corresponding assertion. + */ + +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { relayClient } from "@/shared/api/relayClient.ts"; +import { threadRepliesKey } from "./lib/messageQueryKeys.ts"; +import { useChannelSubscription } from "./hooks.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const ROOT_ID = "1".repeat(64); + +function installDom() { + const dom = new JSDOM( + "
", + ); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + return dom; +} + +// Replace the relay singleton's network methods with resolved stubs so the +// subscribe effect runs its establish path without a websocket. Exposes the +// registered reconnect listener so the test can fire it on demand. +function stubRelayClient() { + const original = { + subscribeToChannelLive: relayClient.subscribeToChannelLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + setVisibleChannelId: relayClient.setVisibleChannelId, + }; + let reconnectListener = () => {}; + relayClient.subscribeToChannelLive = async () => async () => {}; + relayClient.subscribeToReconnects = (listener) => { + reconnectListener = listener; + return () => {}; + }; + relayClient.setVisibleChannelId = () => {}; + return { + triggerReconnect: () => reconnectListener(), + restore() { + Object.assign(relayClient, original); + }, + }; +} + +afterEach(() => { + delete globalThis.window; + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +async function mountSubscription() { + const dom = installDom(); + const stub = stubRelayClient(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = threadRepliesKey(CHANNEL_ID, ROOT_ID); + const seedWarmCache = () => + queryClient.setQueryData(queryKey, [{ id: ROOT_ID }]); + seedWarmCache(); + + const channel = { id: CHANNEL_ID, channelType: "channel" }; + function Harness() { + useChannelSubscription(channel); + return null; + } + const root = createRoot(dom.window.document.getElementById("root")); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness), + ), + ); + }); + const settle = async () => { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + }; + // Let the async subscribe promise resolve and run its establish callback. + await settle(); + + const isStale = () => + queryClient.getQueryState(queryKey)?.isInvalidated === true; + + return { + isStale, + seedWarmCache, + settle, + triggerReconnect: stub.triggerReconnect, + cleanup() { + act(() => root.unmount()); + queryClient.unmount(); + stub.restore(); + }, + }; +} + +test("subscribing invalidates the channel's warm thread-replies cache", async () => { + const h = await mountSubscription(); + try { + assert.equal( + h.isStale(), + true, + "thread-replies cache must be invalidated once the live subscription establishes, so a reopen refetches replies missed while the channel was inactive", + ); + } finally { + h.cleanup(); + } +}); + +test("reconnecting re-invalidates a warm thread-replies cache", async () => { + const h = await mountSubscription(); + try { + h.seedWarmCache(); + assert.equal(h.isStale(), false, "precondition: cache is warm again"); + h.triggerReconnect(); + await h.settle(); + assert.equal( + h.isStale(), + true, + "a reconnect must re-invalidate thread replies: events may have landed while the socket was down", + ); + } finally { + h.cleanup(); + } +}); From 4224bba043f700947cfb060dc361ab8304ffd7ba Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 14:11:08 -0400 Subject: [PATCH 4/5] test(desktop): pin thread-replies warm-cache producer contract The 30s staleTime is safe only while the active channel's live subscription keeps subscribed thread-replies caches current via appendMessage. Two producer paths carry that contract with load-bearing key scoping: a threaded content event writes only its own (channel, root) key, while an aux edit/reaction fans out across every thread key in the channel. Prior tests covered invalidation and cold hydration but not these producers. Also correct the THREAD_PAGE_LIMIT comment: a full 500-reply page returns a non-null cursor and costs a second empty request, so the single-round-trip claim holds for <500 replies, not <=500. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../useChannelSubscriptionProducers.test.mjs | 212 ++++++++++++++++++ .../src/features/messages/useThreadReplies.ts | 5 +- 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/useChannelSubscriptionProducers.test.mjs diff --git a/desktop/src/features/messages/useChannelSubscriptionProducers.test.mjs b/desktop/src/features/messages/useChannelSubscriptionProducers.test.mjs new file mode 100644 index 00000000000..62222cc9e03 --- /dev/null +++ b/desktop/src/features/messages/useChannelSubscriptionProducers.test.mjs @@ -0,0 +1,212 @@ +/** + * Seam test for the warm-cache producer contract that makes the thread-replies + * `staleTime: 30s` safe. + * + * A warm thread-replies cache is only trustworthy on reopen because the active + * channel's live subscription keeps it current: `useChannelSubscription`'s + * `appendMessage` writes each live event into the cache. Two producer paths + * carry that contract, and their key scoping is load-bearing: + * + * - A live threaded *content* event writes only to its own + * `(channel, root)` thread-replies key -- a reply under one root must not + * leak into a sibling thread's cache. + * - A live *aux* event (edit/reaction/deletion) carries no thread reference, + * so it fans out across every existing `["thread-replies", channelId]` + * cache -- an overlay can apply to any thread the reaction targets. + * + * The existing `useChannelSubscription.test.mjs` pins invalidation and this + * file pins the producers. Falsifying either key scoping (content fan-out, or + * aux narrowing to one key) fails the matching assertion. + */ + +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { + KIND_REACTION, + KIND_STREAM_MESSAGE, +} from "@/shared/constants/kinds.ts"; +import { relayClient } from "@/shared/api/relayClient.ts"; +import { threadRepliesKey } from "./lib/messageQueryKeys.ts"; +import { useChannelSubscription } from "./hooks.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const ROOT_A = "a".repeat(64); +const ROOT_B = "b".repeat(64); + +function installDom() { + const dom = new JSDOM( + "
", + ); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + return dom; +} + +// Replace the relay singleton's network methods with resolved stubs and expose +// the live-event listener so the test can push events through the real +// `appendMessage` producer path. +function stubRelayClient() { + const original = { + subscribeToChannelLive: relayClient.subscribeToChannelLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + setVisibleChannelId: relayClient.setVisibleChannelId, + }; + let liveListener = () => {}; + relayClient.subscribeToChannelLive = async (_channelId, listener) => { + liveListener = listener; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.setVisibleChannelId = () => {}; + return { + emit: (event) => liveListener(event), + restore() { + Object.assign(relayClient, original); + }, + }; +} + +afterEach(() => { + delete globalThis.window; + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +function relayEvent(overrides) { + return { + id: "0".repeat(64), + pubkey: "f".repeat(64), + created_at: 1_000, + kind: KIND_STREAM_MESSAGE, + tags: [], + content: "", + sig: "", + ...overrides, + }; +} + +async function mountSubscription() { + const dom = installDom(); + const stub = stubRelayClient(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const keyA = threadRepliesKey(CHANNEL_ID, ROOT_A); + const keyB = threadRepliesKey(CHANNEL_ID, ROOT_B); + // Two warm sibling-thread caches so a leak across roots is observable. + queryClient.setQueryData(keyA, [relayEvent({ id: ROOT_A })]); + queryClient.setQueryData(keyB, [relayEvent({ id: ROOT_B })]); + + const channel = { id: CHANNEL_ID, channelType: "channel" }; + function Harness() { + useChannelSubscription(channel); + return null; + } + const root = createRoot(dom.window.document.getElementById("root")); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness), + ), + ); + }); + const settle = async () => { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + }; + // Let the async subscribe promise resolve so the live listener is registered. + await settle(); + + const idsIn = (key) => + (queryClient.getQueryData(key) ?? []).map((event) => event.id); + + return { + idsIn, + keyA, + keyB, + async emit(event) { + await act(async () => { + stub.emit(event); + }); + }, + cleanup() { + act(() => root.unmount()); + queryClient.unmount(); + stub.restore(); + }, + }; +} + +test("a live threaded content event updates only its own (channel, root) key", async () => { + const h = await mountSubscription(); + try { + const replyId = "1".repeat(64); + await h.emit( + relayEvent({ + id: replyId, + kind: KIND_STREAM_MESSAGE, + created_at: 2_000, + tags: [ + ["e", ROOT_A, "", "root"], + ["e", ROOT_A, "", "reply"], + ], + }), + ); + assert.deepEqual( + h.idsIn(h.keyA), + [ROOT_A, replyId], + "the reply must append to its own root's thread-replies cache", + ); + assert.deepEqual( + h.idsIn(h.keyB), + [ROOT_B], + "the reply must not leak into a sibling thread's cache", + ); + } finally { + h.cleanup(); + } +}); + +test("a live aux event updates every existing thread key in the channel", async () => { + const h = await mountSubscription(); + try { + const reactionId = "2".repeat(64); + await h.emit( + relayEvent({ + id: reactionId, + kind: KIND_REACTION, + created_at: 2_000, + content: "+", + tags: [["e", ROOT_A, "", "reply"]], + }), + ); + assert.deepEqual( + h.idsIn(h.keyA), + [ROOT_A, reactionId], + "an aux overlay must fan out to the channel's existing thread caches", + ); + assert.deepEqual( + h.idsIn(h.keyB), + [ROOT_B, reactionId], + "an aux overlay must fan out to the channel's existing thread caches", + ); + } finally { + h.cleanup(); + } +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 452c27f8c42..da8330fea1b 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -20,7 +20,10 @@ import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; // The bridge clamps a thread page to BRIDGE_THREAD_MAX_LIMIT (500) and the // Tauri command caps it with `.min(500)`, so 500 is the largest page the server -// will serve — a ≤500-reply thread cold-opens in a single content round trip. +// will serve. A thread with fewer than 500 replies cold-opens in a single +// content round trip; a full 500-reply page returns a non-null cursor (the +// bridge treats every full page as potentially incomplete), so exactly 500 +// costs a second, empty content request. export const THREAD_PAGE_LIMIT = 500; const MAX_THREAD_PAGES = 500; From a8b701c96deab98c90ba285d45da18e1a6152f2e Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 18:16:51 -0400 Subject: [PATCH 5/5] fix(desktop): never make an absent thread cache fresh from a live reply `appendMessage` wrote every live threaded reply into its `(channel, root)` thread-replies key with `setQueryData`, which builds the key when absent. Under the PR's 30s `staleTime` that minted a "complete/fresh" cache whose only row was the live reply for a never-opened thread, so opening it within the window skipped `loadThreadReplies` and silently dropped the entire older subtree. Under the prior `staleTime: 0` the mount always refetched, so this is the PR's own regression. Write through `setQueriesData` with an exact-key filter instead: like the aux fan-out just below, it routes through `findAll` and updates only an already-observed key, so a warm or in-flight thread still appends live while an absent thread stays absent and its next mount fetches the full relay subtree (which includes the persisted reply). Regressions: the freshness contract at the real QueryClient (no key -> live reply -> open runs the history fetch and unions the reply exactly once) and the production exact-500 page-loop contract (a full page returns a cursor, so 500 and 501 replies page twice), both mutation-verified. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/messages/hooks.ts | 13 +- .../messages/threadReplyFreshness.test.mjs | 230 ++++++++++++++++++ .../messages/useThreadReplies.test.mjs | 70 ++++++ 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/threadReplyFreshness.test.mjs diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index fda155d455f..16c5993b473 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -333,8 +333,17 @@ export function useChannelSubscription(channel: Channel | null) { if (threadReference?.parentId != null) { const rootId = threadReference?.rootId; if (rootId) { - queryClient.setQueryData( - threadRepliesKey(channelId, rootId), + // Update only an already-observed thread key; never build a fresh one. + // Under the 30s `staleTime`, a `setQueryData` that creates the key + // would mint a "complete/fresh" cache whose only row is this live + // reply, so opening a never-loaded thread inside the window would skip + // the history fetch and show just this reply. `setQueriesData` writes + // through `findAll`, which matches only existing queries — a warm or + // in-flight thread appends; an absent thread stays absent and its next + // mount fetches the full subtree from the relay (which includes this + // reply). Mirrors the aux fan-out below, which is create-safe already. + queryClient.setQueriesData( + { queryKey: threadRepliesKey(channelId, rootId), exact: true }, (current = []) => mergeMessages(current, event), ); } diff --git a/desktop/src/features/messages/threadReplyFreshness.test.mjs b/desktop/src/features/messages/threadReplyFreshness.test.mjs new file mode 100644 index 00000000000..34ddbeff350 --- /dev/null +++ b/desktop/src/features/messages/threadReplyFreshness.test.mjs @@ -0,0 +1,230 @@ +/** + * Regression for the High finding: a live threaded reply must never make an + * *absent* thread-replies cache key fresh. + * + * `appendMessage` writes each live threaded reply into the channel's + * thread-replies cache so a warm thread stays current on reopen under the 30s + * `staleTime`. The trap: if that write *creates* the key for a thread that was + * never opened, React Query treats the one-row cache as complete/fresh, and the + * next `useThreadReplies` mount skips `loadThreadReplies` — the thread renders + * only the newest live reply and silently drops its entire older subtree. + * + * This drives the real producer (`useChannelSubscription.appendMessage`) against + * a real QueryClient (a stub client cannot exercise `staleTime`), emits a live + * reply for a never-opened thread, then models the thread open with + * `fetchQuery` using the exact key/queryFn/staleTime `useThreadReplies` uses. + * It asserts the open still runs the history fetch and unions the live reply + * without loss or duplication. Reverting the producer to a create-capable + * `setQueryData` makes `fetchQuery` observe a fresh key and skip the fetch, + * failing the `fetchCount === 1` assertion. + */ + +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds.ts"; +import { relayClient } from "@/shared/api/relayClient.ts"; +import { threadRepliesKey } from "./lib/messageQueryKeys.ts"; +import { useChannelSubscription } from "./hooks.ts"; +import { + THREAD_REPLIES_STALE_TIME_MS, + loadThreadReplies, +} from "./useThreadReplies.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const ROOT_ID = "a".repeat(64); +const HISTORY_REPLY_ID = "c".repeat(64); +const LIVE_REPLY_ID = "1".repeat(64); + +function installDom() { + const dom = new JSDOM( + "
", + ); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + return dom; +} + +function relayEvent(overrides) { + return { + id: "0".repeat(64), + pubkey: "f".repeat(64), + created_at: 1_000, + kind: KIND_STREAM_MESSAGE, + tags: [], + content: "", + sig: "", + ...overrides, + }; +} + +function threadedReply(id, createdAt) { + return relayEvent({ + id, + created_at: createdAt, + tags: [ + ["e", ROOT_ID, "", "root"], + ["e", ROOT_ID, "", "reply"], + ], + }); +} + +// Stub the relay/tauri boundary: expose the live listener and count thread +// history fetches. The mount-fetch also fires a best-effort aux backfill; stub +// those to empty so the harness stays hermetic. +function stubBoundary() { + const original = { + subscribeToChannelLive: relayClient.subscribeToChannelLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + setVisibleChannelId: relayClient.setVisibleChannelId, + fetchAuxEventsByReference: relayClient.fetchAuxEventsByReference, + fetchAuxDeletionEventsForAuxEvents: + relayClient.fetchAuxDeletionEventsForAuxEvents, + }; + let liveListener = () => {}; + let fetchCount = 0; + relayClient.subscribeToChannelLive = async (_channelId, listener) => { + liveListener = listener; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.setVisibleChannelId = () => {}; + relayClient.fetchAuxEventsByReference = async () => []; + relayClient.fetchAuxDeletionEventsForAuxEvents = async () => []; + + const prevInvoke = globalThis.__TAURI_INTERNALS__; + const internals = { + invoke: async (cmd) => { + if (cmd === "get_thread_replies") { + fetchCount += 1; + // Production walks `thread_metadata` on the relay, so a persisted live + // reply is part of the server subtree. Returning both models that: the + // fixed producer drops the pre-open live reply from cache, and the + // history fetch is what restores it — unioned exactly once. + return { + events: [ + threadedReply(HISTORY_REPLY_ID, 1_500), + threadedReply(LIVE_REPLY_ID, 2_000), + ], + next_cursor: null, + }; + } + return { events: [], next_cursor: null }; + }, + transformCallback: () => Math.random(), + }; + globalThis.__TAURI_INTERNALS__ = internals; + // `@tauri-apps/api/core` reads `window.__TAURI_INTERNALS__`; JSDOM's `window` + // is a distinct object from `globalThis`, so set it on both. + globalThis.window.__TAURI_INTERNALS__ = internals; + + return { + emit: (event) => liveListener(event), + fetches: () => fetchCount, + restore() { + Object.assign(relayClient, original); + globalThis.__TAURI_INTERNALS__ = prevInvoke; + }, + }; +} + +afterEach(() => { + delete globalThis.window; + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +test("a live reply to a never-opened thread does not skip the mount history fetch", async () => { + const dom = installDom(); + const stub = stubBoundary(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const channel = { id: CHANNEL_ID, channelType: "channel" }; + function Harness() { + useChannelSubscription(channel); + return null; + } + const root = createRoot(dom.window.document.getElementById("root")); + const settle = async () => { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + }; + + try { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness), + ), + ); + }); + // Let the async subscribe promise resolve so the live listener registers. + await settle(); + + const key = threadRepliesKey(CHANNEL_ID, ROOT_ID); + // Precondition: the thread has never been opened, so its key is absent. + assert.equal( + queryClient.getQueryData(key), + undefined, + "precondition: never-opened thread has no cache entry", + ); + + // A live reply arrives for that never-opened thread, through the real + // producer path. + await act(async () => { + stub.emit(threadedReply(LIVE_REPLY_ID, 2_000)); + }); + await settle(); + + // The producer must not have created a fresh cache from the live reply. + assert.equal( + queryClient.getQueryData(key), + undefined, + "a live reply must not create a fresh cache for a never-opened thread", + ); + + // Model the thread open exactly as `useThreadReplies` does: fetchQuery with + // the same key/staleTime. If the producer had minted a fresh key, fetchQuery + // would return it without invoking the queryFn. + await queryClient.fetchQuery({ + queryKey: key, + queryFn: () => loadThreadReplies(queryClient, CHANNEL_ID, ROOT_ID), + staleTime: THREAD_REPLIES_STALE_TIME_MS, + }); + await settle(); + + assert.equal( + stub.fetches(), + 1, + "opening the thread must run the history fetch, not treat a live-reply cache as fresh", + ); + + const ids = (queryClient.getQueryData(key) ?? []).map((event) => event.id); + assert.deepEqual( + [...ids].sort(), + [HISTORY_REPLY_ID, LIVE_REPLY_ID].sort(), + "the fetched subtree unions the in-flight live reply exactly once", + ); + } finally { + act(() => root.unmount()); + queryClient.clear(); + queryClient.unmount(); + stub.restore(); + } +}); diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 502f51016e5..281eb404cea 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -127,6 +127,76 @@ test("backfillThreadAux merges over the current cache, not the fetch-time replie ); }); +// Model production's exact-500 paging contract: the bridge returns a non-null +// cursor for *every* full `THREAD_PAGE_LIMIT` page (it treats a full page as +// potentially incomplete), and a null cursor only for a short/empty page. So a +// thread with a multiple of 500 replies costs one extra empty request, and the +// loader must page until the null cursor, unioning every page without loss or +// duplication. The E2E bridge returns a cursor only when rows remain, so this +// exact-500 boundary is not covered there — hence a unit model here. +function pagedFetcher(pageSizes) { + let call = 0; + const uid = (page, index) => + `${page}`.padStart(2, "0").repeat(2) + `${index}`.padStart(60, "0"); + const calls = []; + const fetchPage = async (_rootId, _channelId, { limit, cursor }) => { + calls.push({ limit, cursor }); + const size = pageSizes[call]; + const events = Array.from({ length: size }, (_unused, index) => ({ + ...reply(uid(call, index)), + created_at: 1_700_000_000 + call * 1_000 + index, + })); + // Production: a full page (== limit) is treated as maybe-incomplete and + // returns a cursor; anything short terminates. + const isFull = size === limit; + call += 1; + return { + events, + nextCursor: isFull ? { createdAt: 1, eventId: uid(call, 0) } : null, + }; + }; + return { fetchPage, calls }; +} + +const noopAuxDeps = { + fetchStructuralAux: async () => [], + fetchReactions: async () => [], +}; + +for (const total of [499, 500, 501]) { + test(`loadThreadReplies pages the production exact-500 cursor contract at ${total} replies`, async () => { + const client = makeQueryClientStub([]); + // 499 → [499] (short page, one request). 500 → [500, 0] (full page returns + // a cursor, then an empty terminating page). 501 → [500, 1]. + const pageSizes = + total < THREAD_PAGE_LIMIT + ? [total] + : [THREAD_PAGE_LIMIT, total - THREAD_PAGE_LIMIT]; + const { fetchPage, calls } = pagedFetcher(pageSizes); + + const result = await loadThreadReplies(client, CHANNEL_ID, ROOT_ID, { + fetchPage, + auxDeps: noopAuxDeps, + }); + + assert.equal( + calls.length, + pageSizes.length, + "must issue exactly one request per page until the null cursor", + ); + assert.ok( + calls.every((c) => c.limit === THREAD_PAGE_LIMIT), + "every page requests the server-clamped 500 maximum", + ); + assert.equal(result.length, total, "the union spans every paged reply"); + assert.equal( + new Set(result.map((event) => event.id)).size, + total, + "paged replies union without duplication", + ); + }); +} + function deferred() { let resolve; let reject;