diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 03e5378d8..c30f98a3c 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -229,6 +229,7 @@ export function resolveThreadReplyTarget( } export const CHANNEL_TIMELINE_GC_TIME_MS = 60 * 60 * 1_000; +export const CHANNEL_TIMELINE_STALE_TIME_MS = 5 * 60 * 1_000; export function useChannelWindowQuery(channel: Channel | null) { const queryClient = useQueryClient(); @@ -372,7 +373,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { signal, ); }, - staleTime: 5 * 60 * 1_000, + staleTime: CHANNEL_TIMELINE_STALE_TIME_MS, gcTime: CHANNEL_TIMELINE_GC_TIME_MS, }); } @@ -498,19 +499,27 @@ export function useChannelSubscription(channel: Channel | null) { } cleanup = dispose; - // The live subscription starts at "now", so it cannot close the gap - // 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. - void refreshNewestWindow().catch((error) => { - if (!isDisposed) { - console.error( - "Failed to refresh channel window after subscribing", - channelId, - error, - ); - } - }); + // A fresh cached window already covers ordinary warm navigation. Only + // close the page-to-live gap when the snapshot is absent or old; + // reconnects remain an explicit unconditional catch-up above. + const queryState = queryClient.getQueryState( + channelMessagesKey(channelId), + ); + const snapshotIsFresh = + queryState?.data !== undefined && + Date.now() - queryState.dataUpdatedAt < + CHANNEL_TIMELINE_STALE_TIME_MS; + if (!snapshotIsFresh) { + void refreshNewestWindow().catch((error) => { + if (!isDisposed) { + console.error( + "Failed to refresh channel window after subscribing", + channelId, + error, + ); + } + }); + } }) .catch((error) => { console.error("Failed to subscribe to channel", channelId, error); @@ -523,7 +532,7 @@ export function useChannelSubscription(channel: Channel | null) { void cleanup(); } }; - }, [channelId, channelType]); + }, [channelId, channelType, queryClient]); } export function useSendMessageMutation( diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1ff205d7a..7e730d4cc 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -739,7 +739,9 @@ const MessageTimelineBase = React.forwardRef< {useTimelineVirtualizer && timelineList ? (
{timelineList}
@@ -844,7 +846,7 @@ const MessageTimelineBase = React.forwardRef< )} - {!isAtBottom ? ( + {!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
; }; -export function MessageRowItem({ +export const MessageRowItem = React.memo(function MessageRowItem({ channelId, currentPubkey, entry, @@ -122,6 +122,13 @@ export function MessageRowItem({ unfollowThreadById, videoReviewContext, }: MessageRowItemProps) { + if (import.meta.env.MODE === "e2e" && typeof window !== "undefined") { + const probe = window as unknown as { + __TIMELINE_ROW_RENDER_COUNT__?: number; + }; + probe.__TIMELINE_ROW_RENDER_COUNT__ = + (probe.__TIMELINE_ROW_RENDER_COUNT__ ?? 0) + 1; + } const { message, summary } = entry; const canManage = canManageMessageForCurrentUser( message, @@ -225,4 +232,4 @@ export function MessageRowItem({ {footer}
); -} +}); diff --git a/desktop/src/features/messages/ui/timelineRetention.ts b/desktop/src/features/messages/ui/timelineRetention.ts index d1c50a37a..9620a6254 100644 --- a/desktop/src/features/messages/ui/timelineRetention.ts +++ b/desktop/src/features/messages/ui/timelineRetention.ts @@ -14,11 +14,11 @@ export function nextRetainedTimelineKeys( const offset = list.scrollOffset; const indexAt = (target: number) => list.findItemIndex(Math.min(list.scrollSize, Math.max(0, target))); - const admissionStart = indexAt(offset - viewportSize * 8); - const admissionEnd = indexAt(offset + viewportSize * 9); - const evictionStart = indexAt(offset - viewportSize * 12); - const evictionEnd = indexAt(offset + viewportSize * 13); - const tailStart = indexAt(list.scrollSize - viewportSize * 3); + const admissionStart = indexAt(offset - viewportSize * 2); + const admissionEnd = indexAt(offset + viewportSize * 2); + const evictionStart = indexAt(offset - viewportSize * 3); + const evictionEnd = indexAt(offset + viewportSize * 3); + const tailStart = indexAt(list.scrollSize - viewportSize); const next = new Set(); for (let index = evictionStart; index <= evictionEnd; index += 1) { diff --git a/desktop/src/features/messages/ui/useTimelineRetention.test.mjs b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs index d965e1901..5f8449763 100644 --- a/desktop/src/features/messages/ui/useTimelineRetention.test.mjs +++ b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs @@ -67,20 +67,26 @@ it("does not keep the full timeline mounted before the viewport is measured", as const root = createRoot(document.getElementById("root")); await act(async () => root.render(React.createElement(Harness))); - assert.equal(retention.retainedIndices.length, 100); - assert.equal(retention.retainedIndices[0], 9_900); + assert.equal(retention.retainedIndices.length, 8); + assert.equal(retention.retainedIndices[0], 9_992); assert.equal(retention.retainedIndices.at(-1), 9_999); await act(async () => initialRefresh()); - assert.ok(retention.retainedIndices.length > 0); - assert.ok(retention.retainedIndices.length < 500); + assert.equal(retention.retainedIndices.length, 51); + assert.ok(retention.retainedIndices.includes(4_980)); assert.ok(retention.retainedIndices.includes(5_000)); + assert.ok(retention.retainedIndices.includes(5_020)); + assert.ok(!retention.retainedIndices.includes(4_979)); + assert.ok(!retention.retainedIndices.includes(5_021)); + assert.ok(retention.retainedIndices.includes(9_990)); assert.ok(retention.retainedIndices.includes(9_999)); await act(async () => retention.onScrollEnd()); - assert.ok(retention.retainedIndices.length > 0); - assert.ok(retention.retainedIndices.length < 500); + assert.equal(retention.retainedIndices.length, 51); + assert.ok(retention.retainedIndices.includes(4_980)); assert.ok(retention.retainedIndices.includes(5_000)); + assert.ok(retention.retainedIndices.includes(5_020)); + assert.ok(retention.retainedIndices.includes(9_990)); assert.ok(retention.retainedIndices.includes(9_999)); await act(async () => root.unmount()); diff --git a/desktop/src/features/messages/ui/useTimelineRetention.ts b/desktop/src/features/messages/ui/useTimelineRetention.ts index 05336d4a1..f1af7f7d7 100644 --- a/desktop/src/features/messages/ui/useTimelineRetention.ts +++ b/desktop/src/features/messages/ui/useTimelineRetention.ts @@ -2,7 +2,7 @@ import * as React from "react"; import type { VListHandle } from "virtua"; import { nextRetainedTimelineKeys } from "./timelineRetention"; -const INITIAL_RETAINED_TAIL_SIZE = 100; +const INITIAL_RETAINED_TAIL_SIZE = 8; export function useTimelineRetention( keys: readonly string[], diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bc785ac25..bf7516e03 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5139,10 +5139,6 @@ async function handleGetChannelWindow( return relayQuery(config, [filter]); }; - if (!args.cursor) { - return execute(); - } - const probe = window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number; __CHANNEL_WINDOW_INFLIGHT__?: number; @@ -5151,6 +5147,10 @@ async function handleGetChannelWindow( probe.__CHANNEL_WINDOW_FETCH_COUNT__ = (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; + if (!args.cursor) { + return execute(); + } + const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0; if (delayMs <= 0) { return execute(); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 61a2753ba..aced1c109 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -236,6 +236,19 @@ test.describe("list virtualization", () => { ); }, expectedId); + // Transfer scroll ownership away from initial bottom settling with the same + // native input path a reader uses. The deterministic scrollTop assignments + // below position each boundary crossing; by themselves they do not emit + // wheel/pointer/touch intent and therefore cannot retire bottom settling. + const initialBox = await timeline.boundingBox(); + if (!initialBox) throw new Error("timeline has no bounding box"); + await page.mouse.move( + initialBox.x + initialBox.width / 2, + initialBox.y + initialBox.height / 2, + ); + await page.mouse.wheel(0, -1); + await page.waitForTimeout(50); + // Load fifteen consecutive server pages in one mounted virtualizer. This // is the production shape that exposed the intermittent end-cache snap: // variable-height rows and repeated front insertions exercise the full @@ -563,16 +576,18 @@ test.describe("list virtualization", () => { }); }); -test("thread-heavy history mounts every loaded row", async ({ page }) => { +test("thread-heavy history keeps a bounded painted viewport", async ({ + page, +}) => { await installMockBridge(page); await page.goto("/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); - // Seed summaries on 120 loaded roots. Every loaded row should be realized - // immediately so first-pass scrolling never encounters Virtua's hidden - // pre-measurement state. + // Seed summaries on 120 loaded roots. The bounded retention window should + // still paint every mounted row, without realizing the complete 50-root + // relay page before the reader moves through it. await page.evaluate(() => { for (let index = 480; index < 600; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ @@ -603,9 +618,15 @@ test("thread-heavy history mounts every loaded row", async ({ page }) => { await page.waitForTimeout(300); const loadedRows = timeline.locator("[data-message-id]"); - // The mock channel's current loaded window contains 50 roots; all of them - // must already exist and be painted before the first scroll gesture. - await expect(loadedRows).toHaveCount(50); + const loadedRowCount = await loadedRows.count(); + // The current relay window still carries 50 roots, while only the viewport + // plus the narrow keep-mounted bands should exist as live row DOM. + expect(loadedRowCount).toBeGreaterThan(6); + expect(loadedRowCount).toBeLessThan(50); + const completeSnapshotCount = await page + .locator("[data-live-message-count]") + .evaluate((element) => Number(element.dataset.liveMessageCount ?? "0")); + expect(completeSnapshotCount).toBeGreaterThan(loadedRowCount); expect( await loadedRows.evaluateAll((rows) => rows.every((row) => getComputedStyle(row).visibility === "visible"), @@ -810,10 +831,8 @@ test("live tail arrivals stay buffered while reading and release on jump", async const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); - await timeline.evaluate((element) => { - element.scrollTop = Math.max(500, element.scrollHeight / 2); - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); + await timeline.hover(); + await page.mouse.wheel(0, -800); await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible(); const frozenHeight = await timeline.evaluate( (element) => element.scrollHeight, diff --git a/desktop/tests/e2e/warm-switch-markdown.perf.ts b/desktop/tests/e2e/warm-switch-markdown.perf.ts index 291a052f1..fb7d1f655 100644 --- a/desktop/tests/e2e/warm-switch-markdown.perf.ts +++ b/desktop/tests/e2e/warm-switch-markdown.perf.ts @@ -9,23 +9,21 @@ import { installMockBridge } from "../helpers/bridge"; * already in the React Query cache (the everyday alt-tab-between-channels * motion). The timeline subtree is keyed by channel id — required so TanStack * Router's scroll restoration never writes a stale scrollTop into a reused - * scroll node — so every switch unmounts and remounts all rows, and each - * `MessageRow` re-runs the synchronous react-markdown parse pipeline from - * scratch. This spec is the instrument for that cost. + * scroll node. Warm entry therefore mounts a bounded route-correct slice first, + * then releases the complete cached snapshot in one transition. This spec gates + * that cost and proves the release cannot strand a partial timeline. * * TWO SCENARIOS, one per axis of the cost: - * plain-text — `deep-history` (600 seeded one-line rows; the initial - * channel window mounts ~50 of them, verified by parse - * count): isolates the per-row remount floor. + * plain-text — `deep-history` (600 seeded one-line rows; the cached + * channel window retains 50): isolates the remount floor. * markdown — `random` + 60 injected markdown-heavy rows (code fences, - * tables, lists, mentions, links): isolates the parse cost the - * markdown cache is meant to remove. + * tables, lists, mentions, links): exercises expensive visible + * DOM creation and style recalculation. * * WHAT A "SWITCH" MEASURES: performance.now() immediately before an in-page - * .click() on the sidebar link, until (chat title flipped) AND (>= 1 message - * row committed) AND (no [data-render-pending="true"], i.e. the deferred - * timeline snapshot caught up to the live one) AND a double-rAF so a frame - * actually painted. The click and the polling both run in-page so CDP + * .click() on the sidebar link, until (chat title flipped) AND the target + * channel's expected visible viewport row is painted at the restored bottom + * offset with the complete cached snapshot admitted. The click and the * round-trip latency never pollutes the numbers. Longtask totals are captured * per switch as the "UI froze" axis (see cold-switch-longtask.perf.ts for the * rationale). @@ -37,8 +35,8 @@ import { installMockBridge } from "../helpers/bridge"; * the same machine are. * * Run it (from desktop/): - * pnpm build - * npx playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts + * pnpm build:e2e + * pnpm exec playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts * * NOTE: the perf web server reuses an existing server on :4173 — if one is * already running, kill it or make sure `dist/` is freshly built, otherwise @@ -48,6 +46,10 @@ import { installMockBridge } from "../helpers/bridge"; const MEASURED_SWITCHES = 8; const THROTTLE_RATE = 4; const MARKDOWN_MESSAGE_COUNT = 60; +const PLAIN_MAX_LONGTASK_MS = 300; +const MARKDOWN_MAX_LONGTASK_MS = 300; +const PLAIN_MAX_CORRECT_PAINT_MS = 550; +const MARKDOWN_MAX_CORRECT_PAINT_MS = 325; /** One representative agent-style message: fence, table, list, mention, * emphasis, inline code, and a link — the mix real Buzz channels carry. */ @@ -80,6 +82,19 @@ type SwitchSample = { longtaskTotal: number; longtaskMax: number; longtaskCount: number; + liveMessageCount: number; + renderedMessageCount: number; + visibleMessageIds: string[]; + distanceFromBottom: number; + rowRenderCount: number; + mountedRowCount: number; +}; + +type ScenarioResult = { + samples: SwitchSample[]; + cachedMessageCount: number; + expectedVisibleMessageIds: string[]; + windowFetches: number; }; function median(values: number[]): number { @@ -108,15 +123,19 @@ async function waitForMockLiveSubscription( } /** Click the sidebar link and poll — all in-page — until the target channel's - * rows are committed, the deferred snapshot has caught up, and a frame - * painted. Returns wall-clock ms plus the longtasks observed in the window. */ + * correct visible viewport is committed and a frame paints. Returns wall-clock + * ms plus the longtasks observed in the window. */ async function measureSwitch( page: import("@playwright/test").Page, input: { targetTestId: string; targetTitle: string; rowSelector: string }, ): Promise { return page.evaluate(async (args) => { - const store = window as unknown as { __LONGTASKS__: number[] }; + const store = window as unknown as { + __LONGTASKS__: number[]; + __TIMELINE_ROW_RENDER_COUNT__?: number; + }; store.__LONGTASKS__ = []; + store.__TIMELINE_ROW_RENDER_COUNT__ = 0; const link = document.querySelector( `[data-testid="${args.targetTestId}"]`, ); @@ -131,10 +150,27 @@ async function measureSwitch( const title = document.querySelector( '[data-testid="chat-title"]', )?.textContent; + const timeline = document.querySelector( + '[data-testid="message-timeline"]', + ); + const counts = document.querySelector( + "[data-rendered-message-count]", + ); + const snapshotsMatch = + Number(counts?.dataset.liveMessageCount ?? "0") > 0 && + counts?.dataset.renderedMessageCount === + counts?.dataset.liveMessageCount; + const atBottom = timeline + ? timeline.scrollHeight - + timeline.clientHeight - + timeline.scrollTop <= + 2 + : false; const ready = title === args.targetTitle && document.querySelector(args.rowSelector) !== null && - document.querySelector('[data-render-pending="true"]') === null; + snapshotsMatch && + atBottom; if (ready) { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); return; @@ -150,11 +186,46 @@ async function measureSwitch( const elapsed = performance.now() - start; const tasks = store.__LONGTASKS__ ?? []; + const snapshot = document.querySelector( + "[data-rendered-message-count]", + ); + const timeline = document.querySelector( + '[data-testid="message-timeline"]', + ); + const timelineRect = timeline?.getBoundingClientRect(); + const visibleMessageIds = + timeline && timelineRect + ? Array.from( + timeline.querySelectorAll("[data-message-id]"), + ) + .filter((row) => { + const rect = row.getBoundingClientRect(); + return ( + rect.bottom > timelineRect.top && rect.top < timelineRect.bottom + ); + }) + .map((row) => row.dataset.messageId ?? "") + .filter(Boolean) + : []; + const distanceFromBottom = timeline + ? timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop + : Number.POSITIVE_INFINITY; + const liveMessageCount = Number(snapshot?.dataset.liveMessageCount ?? "0"); + const renderedMessageCount = Number( + snapshot?.dataset.renderedMessageCount ?? "0", + ); return { ms: elapsed, longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0), longtaskMax: tasks.length ? Math.max(...tasks) : 0, longtaskCount: tasks.length, + liveMessageCount, + renderedMessageCount, + visibleMessageIds, + distanceFromBottom, + rowRenderCount: store.__TIMELINE_ROW_RENDER_COUNT__ ?? 0, + mountedRowCount: + timeline?.querySelectorAll("[data-message-id]").length ?? 0, }; }, input); } @@ -167,17 +238,27 @@ async function runScenario( targetTitle: string; rowSelector: string; }, -): Promise { +): Promise { const back = { targetTestId: "channel-general", targetTitle: "general", rowSelector: "[data-message-id]", }; - // Untimed warmup round-trip: caches both channels' queries. - await measureSwitch(page, input); + // Untimed warmup round-trip: caches both channels' queries. Its settled + // message count is the complete cached window every measured re-entry must + // release after the bounded first paint. + const warmup = await measureSwitch(page, input); + const cachedMessageCount = warmup.liveMessageCount; + const expectedVisibleMessageIds = warmup.visibleMessageIds; await measureSwitch(page, back); + await page.evaluate(() => { + ( + window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number } + ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0; + }); + const samples: SwitchSample[] = []; for (let run = 0; run < MEASURED_SWITCHES; run += 1) { samples.push(await measureSwitch(page, input)); @@ -195,6 +276,11 @@ async function runScenario( console.log( `per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`, ); + console.log( + `row renders / mounted: [${samples + .map((sample) => `${sample.rowRenderCount}/${sample.mountedRowCount}`) + .join(", ")}]`, + ); console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`); console.log( `MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`, @@ -203,7 +289,53 @@ async function runScenario( `worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`, ); /* eslint-enable no-console */ - return samples; + const windowFetches = await page.evaluate( + () => + (window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }) + .__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, + ); + return { + samples, + cachedMessageCount, + expectedVisibleMessageIds, + windowFetches, + }; +} + +async function fetchOneOlderWindow( + page: import("@playwright/test").Page, +): Promise { + return page.evaluate(async () => { + const timeline = document.querySelector( + '[data-testid="message-timeline"]', + ); + if (!timeline) throw new Error("missing message timeline"); + const probe = window as unknown as { + __CHANNEL_WINDOW_FETCH_COUNT__?: number; + }; + probe.__CHANNEL_WINDOW_FETCH_COUNT__ = 0; + + for (let step = 0; step < 400; step += 1) { + timeline.scrollBy(0, -300); + await new Promise((resolve) => + requestAnimationFrame(() => window.setTimeout(resolve, 25)), + ); + if ((probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) > 0) break; + } + + const deadline = performance.now() + 5_000; + while (document.querySelector('[data-render-pending="true"]') !== null) { + if (performance.now() > deadline) { + throw new Error("older window did not finish rendering"); + } + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + // Give a faulty re-armed observer time to issue a duplicate request. + await new Promise((resolve) => window.setTimeout(resolve, 250)); + return probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0; + }); } test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async ({ @@ -285,9 +417,55 @@ test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }); - // Instrument, not a gate: assert the harness measured real work. - expect(plain.length).toBe(MEASURED_SWITCHES); - expect(markdown.length).toBe(MEASURED_SWITCHES); - expect(plain.every((sample) => sample.ms > 0)).toBe(true); - expect(markdown.every((sample) => sample.ms > 0)).toBe(true); + expect(plain.samples).toHaveLength(MEASURED_SWITCHES); + expect(markdown.samples).toHaveLength(MEASURED_SWITCHES); + expect(plain.cachedMessageCount).toBeGreaterThan(6); + expect(markdown.cachedMessageCount).toBeGreaterThan(6); + expect(plain.expectedVisibleMessageIds.length).toBeGreaterThan(0); + expect(markdown.expectedVisibleMessageIds.length).toBeGreaterThan(0); + expect( + plain.samples.every( + (sample) => + sample.liveMessageCount === plain.cachedMessageCount && + sample.visibleMessageIds.some((id) => + plain.expectedVisibleMessageIds.includes(id), + ) && + sample.distanceFromBottom <= 2, + ), + ).toBe(true); + expect( + markdown.samples.every( + (sample) => + sample.liveMessageCount === markdown.cachedMessageCount && + sample.visibleMessageIds.some((id) => + markdown.expectedVisibleMessageIds.includes(id), + ) && + sample.distanceFromBottom <= 2, + ), + ).toBe(true); + + expect(plain.windowFetches).toBe(0); + expect(markdown.windowFetches).toBe(0); + expect( + Math.max(...plain.samples.map((sample) => sample.longtaskMax)), + ).toBeLessThanOrEqual(PLAIN_MAX_LONGTASK_MS); + expect( + Math.max(...markdown.samples.map((sample) => sample.longtaskMax)), + ).toBeLessThanOrEqual(MARKDOWN_MAX_LONGTASK_MS); + expect( + Math.max(...plain.samples.map((sample) => sample.ms)), + ).toBeLessThanOrEqual(PLAIN_MAX_CORRECT_PAINT_MS); + expect( + Math.max(...markdown.samples.map((sample) => sample.ms)), + ).toBeLessThanOrEqual(MARKDOWN_MAX_CORRECT_PAINT_MS); + + // A genuine older-history reach remains network-backed and bounded to one + // channel-window request. The warm-navigation assertions above prove the + // same counter stays at zero when no continuation is needed. + await measureSwitch(page, { + targetTestId: "channel-deep-history", + targetTitle: "deep-history", + rowSelector: '[data-message-id^="mock-deep-history-"]', + }); + expect(await fetchOneOlderWindow(page)).toBe(1); });