From 5a42d3706a87f7b5a35827f9ae5157a2c28f23df Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 18 Jun 2026 16:38:22 -0400 Subject: [PATCH] =?UTF-8?q?test(desktop):=20scroll-smoothness=20perf=20har?= =?UTF-8?q?ness=20=E2=80=94=20steady-state=20+=20prepend=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instrument, not a gate. Seeds a busy channel against the mock bridge and measures the main-thread cost the headless correctness suite cannot feel. Two measurements: - Fast-wheel scroll of the bounded ~200-row window: compositor-cheap (~0.2ms layout + 1.3ms recalc over a 241-frame, 12k-px burst). - Prepend re-render cost while scrolled up: ~9-13ms main-thread tick, attributable to the O(rendered-rows) parent walk (videoReviewContext rebuild + day-group boundaries + element construction), NOT layout and NOT leaf-row reconciliation (MessageRow memo bails correctly). Anchor holds at 0px drift on this path. Scope limit: measures Chromium reconciliation, not the WKWebView compositor feel — that remains the real-Tauri macOS pass. Run: pnpm build && npx playwright test --config=playwright.perf.config.ts Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/playwright.perf.config.ts | 23 ++ desktop/tests/e2e/scroll-smoothness.perf.ts | 311 ++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 desktop/playwright.perf.config.ts create mode 100644 desktop/tests/e2e/scroll-smoothness.perf.ts diff --git a/desktop/playwright.perf.config.ts b/desktop/playwright.perf.config.ts new file mode 100644 index 0000000000..6fde2c28f8 --- /dev/null +++ b/desktop/playwright.perf.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 60_000, + retries: 0, + workers: 1, + reporter: [["list"]], + use: { baseURL: "http://127.0.0.1:4173" }, + projects: [ + { + name: "perf", + testMatch: ["**/*.perf.ts"], + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "python3 -m http.server 4173 -d dist", + cwd: ".", + reuseExistingServer: true, + url: "http://127.0.0.1:4173", + }, +}); diff --git a/desktop/tests/e2e/scroll-smoothness.perf.ts b/desktop/tests/e2e/scroll-smoothness.perf.ts new file mode 100644 index 0000000000..06fec8baef --- /dev/null +++ b/desktop/tests/e2e/scroll-smoothness.perf.ts @@ -0,0 +1,311 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Scroll-smoothness measurement harness. + * + * This is NOT a pass/fail correctness test — it's an instrument. It seeds a + * busy channel, mounts the full history into the DOM, drives a synthetic + * fast-wheel scroll up through it, and measures the *main-thread layout work* + * that scroll triggers — the cost the headless correctness suite cannot feel + * (it polls only the final position). + * + * WHY LAYOUT COST, NOT FRAME CADENCE: under Playwright's headed Chromium there + * is no vsync throttle, so requestAnimationFrame fires far faster than a real + * 60Hz display and "frame interval" tells you nothing about paint jank. The + * honest, engine-agnostic signal is the cumulative layout / style-recalc time + * Chromium spends servicing the scroll burst, read via CDP Performance metrics. + * More live DOM rows + per-row reflow on scroll == more layout cost == the jank + * a user feels. That is exactly the axis virtualization / content-visibility + * attack, so it's the axis worth a baseline. + * + * SCOPE LIMIT (stated honestly): this measures Chromium's main-thread layout + * cost. It does NOT measure the WKWebView compositor / stale-scrollTop race + * Sami flagged as the dominant *feel* hazard on the shipped Tauri app — that + * only reproduces in the real desktop shell and is Tyler's real-wheel pass. + * + * Run headed to watch it: + * pnpm build && npx playwright test --config=playwright.perf.config.ts --headed + */ + +const SEED_ROWS = 600; // a genuinely busy channel, fully mounted +const LINES_PER_ROW = 3; + +type Sample = { + rowCount: number; + scrollSpan: number; + frames: number; +}; + +type Metrics = { layoutMs: number; recalcMs: number; layoutCount: number }; + +async function readMetrics( + client: import("@playwright/test").CDPSession, +): Promise { + const { metrics } = (await client.send("Performance.getMetrics")) as { + metrics: Array<{ name: string; value: number }>; + }; + const m = (name: string) => metrics.find((x) => x.name === name)?.value ?? 0; + return { + // CDP reports durations in seconds; convert to ms. + layoutMs: m("LayoutDuration") * 1000, + recalcMs: m("RecalcStyleDuration") * 1000, + layoutCount: m("LayoutCount"), + }; +} + +test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timeline", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + + await page.evaluate( + ({ rows, lines }) => { + // Emit the entire busy channel as live messages so the FULL list mounts + // into the DOM up front — no dependence on the app's fetch-older + // pagination (which caps per-request and is a separate axis). This is the + // un-virtualized DOM we're here to stress. + for (let i = 0; i < rows; i += 1) { + const body = Array.from( + { length: lines }, + (_u, l) => `busy row ${i} line ${l + 1}`, + ).join("\n"); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + }); + } + }, + { rows: SEED_ROWS, lines: LINES_PER_ROW }, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + + // Confirm the list is mounted before measuring. Capture the actual mounted + // count — we don't assume all SEED_ROWS render (the app may cap/window). + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.querySelectorAll("[data-message-id]").length > 50; + }); + await page.waitForTimeout(500); // let live emits settle + + const client = await page.context().newCDPSession(page); + await client.send("Performance.enable"); + + // Reset to bottom, settle, then snapshot metrics around the scroll burst. + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(100); + + const before = await readMetrics(client); + + const sample = await timeline.evaluate(async (element): Promise => { + const el = element as HTMLDivElement; + el.scrollTop = el.scrollHeight; + const startTop = el.scrollTop; + let frames = 0; + + await new Promise((resolve) => { + let elapsed = 0; + let last = performance.now(); + const DURATION_MS = 2_000; + const PX_PER_FRAME = 50; // brisk human flick + + const step = (now: number) => { + elapsed += now - last; + last = now; + frames += 1; + el.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -PX_PER_FRAME, + bubbles: true, + cancelable: true, + }), + ); + el.scrollTop = Math.max(0, el.scrollTop - PX_PER_FRAME); + el.dispatchEvent(new Event("scroll", { bubbles: true })); + if (elapsed < DURATION_MS && el.scrollTop > 0) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + + return { + rowCount: el.querySelectorAll("[data-message-id]").length, + scrollSpan: startTop - el.scrollTop, + frames, + }; + }); + + const after = await readMetrics(client); + await client.send("Performance.disable"); + + const layoutDelta = after.layoutMs - before.layoutMs; + const recalcDelta = after.recalcMs - before.recalcMs; + const layoutCountDelta = after.layoutCount - before.layoutCount; + const perScroll = sample.frames > 0 ? sample.frames : 1; + + /* eslint-disable no-console */ + console.log("\n=== SCROLL SMOOTHNESS BASELINE (Chromium layout cost) ==="); + console.log(`rows mounted (live DOM): ${sample.rowCount}`); + console.log( + `scroll span covered: ${Math.round(sample.scrollSpan)}px`, + ); + console.log(`scroll frames driven: ${sample.frames}`); + console.log(`layout time over burst: ${layoutDelta.toFixed(1)}ms`); + console.log(`style-recalc time over burst: ${recalcDelta.toFixed(1)}ms`); + console.log(`forced layouts (count delta): ${layoutCountDelta}`); + console.log( + `avg layout+recalc per frame: ${((layoutDelta + recalcDelta) / perScroll).toFixed(3)}ms`, + ); + console.log("(>~8ms/frame main-thread work is where 120Hz starts to drop)"); + console.log("=========================================================\n"); + /* eslint-enable no-console */ + + // Instrument, not a gate — just confirm it exercised the full list. + expect(sample.rowCount).toBeGreaterThan(100); + expect(sample.scrollSpan).toBeGreaterThan(500); +}); + +/** + * MEASURE: prepend re-render cost — the one event-cost the team had no number + * for. Steady-state scroll of the bounded 200-row window is compositor-cheap + * (see the test above). The felt jank, if any, lives at the *prepend*: when + * older rows land while you're scrolled up, the whole memoized + * TimelineMessageList re-renders AND the anchor scrollBy fires, all on one + * main-thread tick. If that tick exceeds the frame budget mid-wheel, the + * compositor stalls and you feel it. + * + * This drives the genuine path: seed older history, scroll off-bottom, then + * prepend a small batch via the live-event ingest (the same WS path the relay + * uses), and measure (a) the synchronous wall-time of the tick that flushes + * the React commit + layout, and (b) the CDP layout/recalc cost attributed to + * it. It also records whether the anchor held (scrollTop preserved within the + * row that was under the eye) — a non-restore here is the in-viewport-shift + * bug, not a perf cost. + * + * SCOPE LIMIT: same as above — Chromium main-thread cost, not WKWebView feel. + */ +test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost)", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + + // Seed the channel at roughly the real data-window cap (CHANNEL_HISTORY_LIMIT + // = 200) so the prepend re-renders a full-size list, the worst realistic case. + const SEED = 200; + await page.evaluate( + ({ rows, lines }) => { + for (let i = 0; i < rows; i += 1) { + const body = Array.from( + { length: lines }, + (_u, l) => `seed row ${i} line ${l + 1}`, + ).join("\n"); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + }); + } + }, + { rows: SEED, lines: LINES_PER_ROW }, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.querySelectorAll("[data-message-id]").length > 50; + }); + await page.waitForTimeout(500); + + // Scroll up off the bottom so the anchor is a mid-list row, not "at-bottom" + // (the at-bottom path corrects differently). Land roughly in the middle. + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.scrollTop = Math.floor(el.scrollHeight * 0.4); + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(200); + + const client = await page.context().newCDPSession(page); + await client.send("Performance.enable"); + + const before = await readMetrics(client); + const scrollTopBefore = await timeline.evaluate( + (el) => (el as HTMLDivElement).scrollTop, + ); + + // Prepend a small older batch through the live ingest path and time the tick + // that flushes the resulting React commit + layout + anchor restore. + const tickMs = await page.evaluate(async (count) => { + const t0 = performance.now(); + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ + channelName: "general", + count, + lineCount: 3, + emit: true, + }); + // Force a layout flush so the synchronous commit + anchor scrollBy + reflow + // are all accounted before we stop the clock. Two rAFs span the commit and + // the subsequent paint-prep tick. + await new Promise((r) => requestAnimationFrame(() => r())); + await new Promise((r) => requestAnimationFrame(() => r())); + return performance.now() - t0; + }, 10); + + const after = await readMetrics(client); + const scrollTopAfter = await timeline.evaluate( + (el) => (el as HTMLDivElement).scrollTop, + ); + const rowCountAfter = await timeline.evaluate( + (el) => (el as HTMLDivElement).querySelectorAll("[data-message-id]").length, + ); + await client.send("Performance.disable"); + + const layoutDelta = after.layoutMs - before.layoutMs; + const recalcDelta = after.recalcMs - before.recalcMs; + const anchorDrift = scrollTopAfter - scrollTopBefore; + + /* eslint-disable no-console */ + console.log("\n=== PREPEND RE-RENDER COST (10 older rows, scrolled up) ==="); + console.log(`rows after prepend (live DOM): ${rowCountAfter}`); + console.log(`tick wall-time (commit+layout): ${tickMs.toFixed(2)}ms`); + console.log(`layout time attributed: ${layoutDelta.toFixed(1)}ms`); + console.log(`style-recalc time attributed: ${recalcDelta.toFixed(1)}ms`); + console.log( + `anchor drift (scrollTop delta): ${anchorDrift.toFixed(1)}px (0 = held)`, + ); + console.log("(tick > ~8ms during active wheel is where 120Hz stalls)"); + console.log("===========================================================\n"); + /* eslint-enable no-console */ + + // Instrument, not a gate — just confirm the prepend actually happened. + expect(rowCountAfter).toBeGreaterThan(50); +});