mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): bound warm timeline work
Skip redundant fresh-window catch-up on warm navigation, memoize stable rows, and narrow the retained DOM to the active viewport and tail. Strengthen the warm-switch and scroll regression gates, including buffered live-tail recovery. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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<RelayEvent[]>(
|
||||
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(
|
||||
|
||||
@@ -739,7 +739,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
{useTimelineVirtualizer && timelineList ? (
|
||||
<div
|
||||
className="h-full min-h-0 w-full"
|
||||
data-live-message-count={messages.length}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
data-rendered-message-count={renderedMessages.length}
|
||||
>
|
||||
{timelineList}
|
||||
</div>
|
||||
@@ -844,7 +846,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
{!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center px-4",
|
||||
|
||||
@@ -91,7 +91,7 @@ type MessageRowItemProps = {
|
||||
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
|
||||
};
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
for (let index = evictionStart; index <= evictionEnd; index += 1) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SwitchSample> {
|
||||
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<HTMLElement>(
|
||||
`[data-testid="${args.targetTestId}"]`,
|
||||
);
|
||||
@@ -131,10 +150,27 @@ async function measureSwitch(
|
||||
const title = document.querySelector(
|
||||
'[data-testid="chat-title"]',
|
||||
)?.textContent;
|
||||
const timeline = document.querySelector<HTMLDivElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
);
|
||||
const counts = document.querySelector<HTMLElement>(
|
||||
"[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<HTMLElement>(
|
||||
"[data-rendered-message-count]",
|
||||
);
|
||||
const timeline = document.querySelector<HTMLDivElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
);
|
||||
const timelineRect = timeline?.getBoundingClientRect();
|
||||
const visibleMessageIds =
|
||||
timeline && timelineRect
|
||||
? Array.from(
|
||||
timeline.querySelectorAll<HTMLElement>("[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<SwitchSample[]> {
|
||||
): Promise<ScenarioResult> {
|
||||
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<number> {
|
||||
return page.evaluate(async () => {
|
||||
const timeline = document.querySelector<HTMLDivElement>(
|
||||
'[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<void>((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<void>((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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user