From b3829feef2ce60a476dbc83216062a547e4efa7a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 29 Jul 2026 19:40:35 +0200 Subject: [PATCH] perf(studio): stop timeline scroll work when row virtualization is off The row virtualization stack made the timeline publish a viewport snapshot on every scroll frame and swap `renderClipContent` across every mounted clip at gesture start and settle. Both are windowing concessions, and neither was gated on the flag, so the build users actually run paid for them while mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247. Gate both on the row virtualization flag. The scroll path now stops at the door when the flag is off, so `isScrolling` stays false and resize-driven and programmatic syncs still publish through the immediate path. The flag moves into its own module: the scroll-viewport hook needs to read it, and the virtualization hook already imports the viewport snapshot type back, which would have closed an import cycle. Also release the perf fixture lease from the fixture rather than from the test-hook effect. Loading a fixture writes player state, which changed that effect's dependency identities and tore it down on the next frame, so the lease was revoked moments after it was taken and live iframe discovery overwrote the fixture before the gate could measure it. The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements) next to the existing flag-on one. It refuses the 50,000-element combination, verifies from the mounted DOM that the server under test matches the requested flag, and skips the DOM-size budgets for the unvirtualized build rather than relaxing them, so a skipped budget never reads as a passed one. Verified against a live Studio dev server on the fixture project: flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0 The flag-on arm's fourth run reproducibly reports a 55-58ms long task against a 50ms budget. That is the residual tail of the window swap itself, tracked separately and not addressed here. --- packages/studio/package.json | 5 +- .../src/hooks/useStudioTestHooks.test.tsx | 6 +- .../studio/src/hooks/useStudioTestHooks.ts | 7 +- .../studio/src/player/components/Timeline.tsx | 5 +- .../Timeline.virtualization.test.tsx | 115 +++++++++++ .../timelineRowVirtualizationFlag.ts | 11 ++ .../useTimelineRowVirtualization.ts | 6 +- .../useTimelineScrollViewport.test.tsx | 179 ++++++++++++++---- .../components/useTimelineScrollViewport.ts | 7 + .../components/useTimelineVirtualRows.ts | 5 - .../tests/e2e/timeline-virtualization.mjs | 70 ++++++- 11 files changed, 358 insertions(+), 58 deletions(-) create mode 100644 packages/studio/src/player/components/timelineRowVirtualizationFlag.ts diff --git a/packages/studio/package.json b/packages/studio/package.json index 9094c8ad20..a8d39c3a16 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,9 +49,10 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs", + "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", - "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts" + "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", + "test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index 0c591e44af..816aa4f1b4 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -129,7 +129,11 @@ describe("timeline performance fixture", () => { unsubscribe(); act(() => root.unmount()); expect(window.__studioTest).toBeUndefined(); - expect(hasTimelinePerformanceFixtureLease()).toBe(false); + // The lease outlives the effect on purpose. Loading a fixture changes the + // player state this effect depends on, so the effect tears down right after + // the loader runs; releasing the lease there let live iframe discovery + // overwrite the fixture before anything could measure it. + expect(hasTimelinePerformanceFixtureLease()).toBe(true); }); it("does not mutate state when the fixture request is invalid", () => { diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 1124b22904..88d4f1ca84 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -99,7 +99,12 @@ export function useStudioTestHooks({ }; window.__studioTest = api; return () => { - setTimelinePerformanceFixtureLease(false); + // The lease is deliberately NOT released here. Loading a fixture writes + // player state, which changes this effect's dependency identities and + // tears the effect down on the very next frame. Releasing on teardown + // therefore revoked the lease moments after it was taken, and live iframe + // discovery overwrote the fixture the loader had just installed. The + // lease belongs to the fixture, and a page reload clears it. // delete, not `= undefined`: an own key holding undefined keeps // `"__studioTest" in window` true, which defeats feature detection. delete window.__studioTest; diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 4a43b3bb76..ea84285df9 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -536,7 +536,10 @@ export const Timeline = memo(function Timeline({ blockedClipRef={blockedClipRef} suppressClickRef={suppressClickRef} scrollRef={scrollRef} - renderClipContent={viewport.isScrolling ? undefined : renderClipContent} + // Windowing drops content to mount a row cheaply; unvirtualized it is pure cost. + renderClipContent={ + rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent + } renderClipOverlay={renderClipOverlay} playheadRef={playheadRef} onDrillDown={onDrillDown} diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx index d1dfd46e9c..3875b71f74 100644 --- a/packages/studio/src/player/components/Timeline.virtualization.test.tsx +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -295,3 +295,118 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => { usePlayerStore.getState().reset(); }); }); + +/** + * The flag-off build is the one users get today. It mounts every clip, so the + * scroll-time concessions windowing makes are pure cost there: this block pins + * the timeline to doing no per-frame work at all while a gesture runs. + */ +describe("Timeline without row virtualization", { timeout: 30_000 }, () => { + async function renderUnvirtualizedTimeline() { + vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0"); + vi.resetModules(); + const [{ Timeline }, { usePlayerStore }] = await Promise.all([ + import("./Timeline"), + import("../store/playerStore"), + ]); + usePlayerStore.setState({ + duration: 60, + timelineReady: true, + selectedElementId: "clip-0", + elements: Array.from({ length: 40 }, (_, track) => ({ + id: `clip-${track}`, + label: `Clip ${track}`, + tag: "div", + start: 0, + duration: 10, + track, + })), + }); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => + root.render( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ), + ); + await act(async () => {}); + return { + host, + dispose: () => { + act(() => root.unmount()); + usePlayerStore.getState().reset(); + vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1"); + vi.resetModules(); + }, + }; + } + + it("mounts every clip rather than a window", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + expect(host.querySelectorAll("[data-clip]").length).toBe(40); + } finally { + dispose(); + } + }); + + it("keeps clip content mounted across a scroll gesture", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + expect(scroller).not.toBeNull(); + const richBefore = host.querySelectorAll("[data-rich-content]").length; + expect(richBefore).toBe(40); + + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore); + } finally { + dispose(); + } + }); + + it("does not swap clip content back in after the gesture settles", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + const clip = host.querySelector('[data-el-id="clip-0"]'); + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip); + expect(host.querySelectorAll("[data-rich-content]").length).toBe(40); + } finally { + dispose(); + } + }); + + it("leaves the scroll position alone, so no snapshot round trip happens", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + if (!scroller) throw new Error("Expected a timeline scroll viewport"); + scroller.scrollTop = 400; + await act(async () => { + scroller.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(scroller.scrollTop).toBe(400); + } finally { + dispose(); + } + }); +}); diff --git a/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts b/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts new file mode 100644 index 0000000000..b6239fa753 --- /dev/null +++ b/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts @@ -0,0 +1,11 @@ +/** + * Row virtualization opt-in. Disabled until horizontal windowing and stable + * gesture lifetime land. + * + * It lives in its own module so the scroll-viewport hook can read it without + * importing the virtualization hook that already imports the viewport snapshot + * type back, which would close an import cycle. + */ +export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED = + import.meta.env.DEV === true && + import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1"; diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts index 8bc6acf4c4..d463c30625 100644 --- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts +++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts @@ -4,10 +4,8 @@ import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity"; import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; -import { - STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED, - useTimelineVirtualRows, -} from "./useTimelineVirtualRows"; +import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag"; +import { useTimelineVirtualRows } from "./useTimelineVirtualRows"; interface UseTimelineRowVirtualizationInput { scrollRef: RefObject; diff --git a/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx b/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx index f62c8e7ac0..427ad871d9 100644 --- a/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx +++ b/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx @@ -3,7 +3,6 @@ import React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -25,54 +24,164 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); globalThis.ResizeObserver = originalResizeObserver; resizeCallback = null; document.body.innerHTML = ""; }); -describe("useTimelineScrollViewport", () => { - it("publishes resize, scroll, and settled snapshots", () => { - let hook: ReturnType | undefined; - function Probe() { - hook = useTimelineScrollViewport(useRef(null), []); - return null; - } - - const root = createRoot(document.createElement("div")); - act(() => root.render(React.createElement(Probe))); - const element = document.createElement("div"); - const values = { - left: 0, - top: 0, - width: 640, - height: 240, - scrollWidth: 1200, - scrollHeight: 800, - }; - Object.defineProperties(element, { - scrollLeft: { configurable: true, get: () => values.left }, - scrollTop: { configurable: true, get: () => values.top }, - clientWidth: { configurable: true, get: () => values.width }, - clientHeight: { configurable: true, get: () => values.height }, - scrollWidth: { configurable: true, get: () => values.scrollWidth }, - scrollHeight: { configurable: true, get: () => values.scrollHeight }, - }); +interface Harness { + hook: () => ReturnType< + typeof import("./useTimelineScrollViewport").useTimelineScrollViewport + > | null; + element: HTMLDivElement; + values: { + left: number; + top: number; + width: number; + height: number; + scrollWidth: number; + scrollHeight: number; + }; + unmount: () => void; +} + +/** + * The row-virtualization flag is a module constant, so the env stub has to be in + * place before the hook module is imported. Each harness therefore resets the + * module registry and imports fresh, which is what lets one file exercise both + * flag states. + */ +async function mountHarness(rowVirtualizationEnabled: boolean): Promise { + vi.stubEnv( + "VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", + rowVirtualizationEnabled ? "1" : "0", + ); + vi.resetModules(); + const { useTimelineScrollViewport } = await import("./useTimelineScrollViewport"); + + let current: ReturnType | null = null; + function Probe() { + current = useTimelineScrollViewport(useRef(null), []); + return null; + } + + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + + const element = document.createElement("div"); + const values = { + left: 0, + top: 0, + width: 640, + height: 240, + scrollWidth: 1200, + scrollHeight: 800, + }; + Object.defineProperties(element, { + scrollLeft: { configurable: true, get: () => values.left }, + scrollTop: { configurable: true, get: () => values.top }, + clientWidth: { configurable: true, get: () => values.width }, + clientHeight: { configurable: true, get: () => values.height }, + scrollWidth: { configurable: true, get: () => values.scrollWidth }, + scrollHeight: { configurable: true, get: () => values.scrollHeight }, + }); + + return { hook: () => current, element, values, unmount: () => act(() => root.unmount()) }; +} - act(() => hook?.setScrollRef(element)); - expect(hook?.viewport.clientWidth).toBe(640); +describe("useTimelineScrollViewport with row virtualization on", () => { + it("publishes resize, scroll, and settled snapshots", async () => { + const { hook, element, values, unmount } = await mountHarness(true); + + act(() => hook()?.setScrollRef(element)); + expect(hook()?.viewport.clientWidth).toBe(640); values.width = 800; act(() => resizeCallback?.([], {} as ResizeObserver)); - expect(hook?.viewport.clientWidth).toBe(800); + expect(hook()?.viewport.clientWidth).toBe(800); values.left = 120; values.top = 48; - act(() => hook?.syncScrollViewport(element, true)); + act(() => hook()?.syncScrollViewport(element, true)); act(() => vi.advanceTimersByTime(16)); - expect(hook?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true }); + expect(hook()?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true }); act(() => vi.advanceTimersByTime(100)); - expect(hook?.viewport.isScrolling).toBe(false); - act(() => root.unmount()); + expect(hook()?.viewport.isScrolling).toBe(false); + unmount(); + }); +}); + +describe("useTimelineScrollViewport with row virtualization off", () => { + it("publishes nothing while scrolling", async () => { + const { hook, element, values, unmount } = await mountHarness(false); + + act(() => hook()?.setScrollRef(element)); + const before = hook()?.viewport; + + values.left = 120; + values.top = 48; + act(() => hook()?.syncScrollViewport(element, true)); + act(() => vi.advanceTimersByTime(16)); + + expect(hook()?.viewport).toBe(before); + expect(hook()?.viewport.scrollLeft).toBe(0); + unmount(); + }); + + it("never reports isScrolling, so the settle timer has nothing to undo", async () => { + const { hook, element, unmount } = await mountHarness(false); + + act(() => hook()?.setScrollRef(element)); + act(() => hook()?.syncScrollViewport(element, true)); + act(() => vi.advanceTimersByTime(500)); + + expect(hook()?.viewport.isScrolling).toBe(false); + unmount(); + }); + + it("schedules no frame or timer for a scroll sync", async () => { + const { hook, element, unmount } = await mountHarness(false); + act(() => hook()?.setScrollRef(element)); + + const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame"); + const timerSpy = vi.spyOn(globalThis, "setTimeout"); + act(() => hook()?.syncScrollViewport(element, true)); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(timerSpy).not.toHaveBeenCalled(); + rafSpy.mockRestore(); + timerSpy.mockRestore(); + unmount(); + }); + + it("still publishes resize-driven snapshots", async () => { + const { hook, element, values, unmount } = await mountHarness(false); + + act(() => hook()?.setScrollRef(element)); + expect(hook()?.viewport.clientWidth).toBe(640); + + values.width = 800; + act(() => resizeCallback?.([], {} as ResizeObserver)); + + expect(hook()?.viewport.clientWidth).toBe(800); + unmount(); + }); + + it("still publishes programmatic non-scrolling syncs", async () => { + const { hook, element, values, unmount } = await mountHarness(false); + + act(() => hook()?.setScrollRef(element)); + values.left = 240; + values.top = 96; + act(() => hook()?.syncScrollViewport(element)); + + expect(hook()?.viewport).toMatchObject({ + scrollLeft: 240, + scrollTop: 96, + isScrolling: false, + }); + unmount(); }); }); diff --git a/packages/studio/src/player/components/useTimelineScrollViewport.ts b/packages/studio/src/player/components/useTimelineScrollViewport.ts index f08f558968..53955d6b74 100644 --- a/packages/studio/src/player/components/useTimelineScrollViewport.ts +++ b/packages/studio/src/player/components/useTimelineScrollViewport.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { shouldShowTimelineShortcutHint } from "./timelineLayout"; +import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag"; export interface TimelineScrollViewportSnapshot { readonly scrollLeft: number; @@ -66,6 +67,12 @@ export function useTimelineScrollViewport( const scrollingRef = useRef(false); const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => { + // Row virtualization is the only consumer of the per-frame scroll snapshot. + // With the flag off, publishing it re-rendered every mounted clip on every + // scroll frame and bought nothing, so the scroll path stops here and + // `isScrolling` stays false. Resize-driven and programmatic syncs arrive + // through the immediate path below and still publish. + if (isScrolling && !STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED) return; scrollingRef.current = isScrolling; const publish = () => { viewportRafRef.current = 0; diff --git a/packages/studio/src/player/components/useTimelineVirtualRows.ts b/packages/studio/src/player/components/useTimelineVirtualRows.ts index 7485d81b59..272be24c35 100644 --- a/packages/studio/src/player/components/useTimelineVirtualRows.ts +++ b/packages/studio/src/player/components/useTimelineVirtualRows.ts @@ -4,11 +4,6 @@ import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; import { RULER_H, TRACKS_TOP_PAD, type TimelineRowGeometry } from "./timelineLayout"; -/** Disabled until horizontal windowing and stable gesture lifetime land. */ -export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED = - import.meta.env.DEV === true && - import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1"; - export interface TimelineVirtualRow { readonly index: number; readonly rowKey: number; diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs index 1e28ddac19..504d5e1d9d 100644 --- a/packages/studio/tests/e2e/timeline-virtualization.mjs +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -6,6 +6,13 @@ * * STUDIO_URL=http://127.0.0.1:5190/#project/timeline-virtualization \ * node packages/studio/tests/e2e/timeline-virtualization.mjs + * + * TIMELINE_ROW_VIRTUALIZATION selects which build is under test and defaults to + * "off", the product default. The gate previously only ever ran against a server + * with row virtualization enabled, so the configuration users actually get was + * never measured. The script asserts the configuration it observes rather than + * trusting the caller: the server is configured by whoever started it, and a + * mismatch would otherwise pass silently against the wrong build. */ import { existsSync, readdirSync } from "node:fs"; import { homedir, platform, arch } from "node:os"; @@ -16,6 +23,7 @@ const STUDIO_URL = process.env.STUDIO_URL; const PROFILE = process.env.TIMELINE_PROFILE || "dense-short"; const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000); const TIER = process.env.TIMELINE_TIER || "primary"; +const ROW_VIRTUALIZATION = process.env.TIMELINE_ROW_VIRTUALIZATION || "off"; const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR ? Number(process.env.TIMELINE_CHROME_MAJOR) : null; @@ -26,11 +34,23 @@ if (!STUDIO_URL) { } if ( ![1_000, 50_000].includes(ELEMENT_COUNT) || - !["primary", "low-resource", "high-dpr"].includes(TIER) + !["primary", "low-resource", "high-dpr"].includes(TIER) || + !["off", "on"].includes(ROW_VIRTUALIZATION) ) { console.error( "TIMELINE_ELEMENT_COUNT must be 1000 or 50000; " + - "TIMELINE_TIER must be primary, low-resource, or high-dpr", + "TIMELINE_TIER must be primary, low-resource, or high-dpr; " + + "TIMELINE_ROW_VIRTUALIZATION must be off or on", + ); + process.exit(2); +} +if (ROW_VIRTUALIZATION === "off" && ELEMENT_COUNT === 50_000) { + // An unvirtualized build mounts a DOM node per clip. 50,000 of them does not + // reach a steady state in any useful time, so the run would time out rather + // than report a verdict. Refuse the combination instead of hanging on it. + console.error( + "TIMELINE_ROW_VIRTUALIZATION=off requires TIMELINE_ELEMENT_COUNT=1000; " + + "the unvirtualized build mounts every clip and cannot settle at 50000", ); process.exit(2); } @@ -263,6 +283,22 @@ try { const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE); const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); + // Trust the DOM, not the caller. A virtualized build cannot mount every clip + // and an unvirtualized one cannot avoid it, so the mounted count says which + // build is really being measured. + const observedClipRoots = await page.evaluate( + () => window.__studioTest.readTimelinePerformanceDiagnostics().mountedClipRoots, + ); + const observedRowVirtualization = observedClipRoots <= budgets.maxMountedClipRoots ? "on" : "off"; + if (observedRowVirtualization !== ROW_VIRTUALIZATION) { + throw new Error( + `Requested TIMELINE_ROW_VIRTUALIZATION=${ROW_VIRTUALIZATION} but the server under test ` + + `behaves as ${observedRowVirtualization}: ${observedClipRoots} clip roots mounted for ` + + `${ELEMENT_COUNT} elements against a ${budgets.maxMountedClipRoots} budget. ` + + "Set VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED on the Studio dev server to match.", + ); + } + const runs = []; const interactionLimitMs = TIER === "primary" ? budgets.interactionP95Ms : budgets.constrainedInteractionP95Ms; @@ -272,16 +308,24 @@ try { const run = await collectRun(page); if (index >= budgets.warmupRuns) runs.push(run); } + // Latency, long tasks and memory are product promises and hold for both + // builds. The DOM-size budgets describe what windowing achieves, so they only + // apply when windowing is on. They are skipped explicitly rather than relaxed, + // so a skipped budget never reads as a passed one. + const domBudgetsApply = ROW_VIRTUALIZATION === "on"; for (const run of runs) { - run.passed = + run.responsivenessPassed = run.interactionP95Ms <= interactionLimitMs && run.frameIntervalP95Ms <= frameIntervalLimitMs && - run.longestTaskMs <= longTaskLimitMs && - run.diagnostics.timelineRoots === 1 && - run.diagnostics.mountedRows <= budgets.maxMountedRows && - run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots && - run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow && - run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants; + run.longestTaskMs <= longTaskLimitMs; + run.timelineMounted = run.diagnostics.timelineRoots === 1; + run.domSizePassed = domBudgetsApply + ? run.diagnostics.mountedRows <= budgets.maxMountedRows && + run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots && + run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow && + run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants + : null; + run.passed = run.responsivenessPassed && run.timelineMounted && run.domSizePassed !== false; } await page.evaluate(() => window.__studioTest.resetTimelinePerformanceFixture()); @@ -315,6 +359,14 @@ try { cpuThrottleRate: TIER === "low-resource" ? 4 : 1, tier: TIER, longTaskObserverProbe, + rowVirtualization: ROW_VIRTUALIZATION, + observedClipRootsAtLoad: observedClipRoots, + appliedBudgets: { + interactionP95Ms: interactionLimitMs, + frameIntervalP95Ms: frameIntervalLimitMs, + longTaskLimitMs, + domSizeBudgets: domBudgetsApply ? "applied" : "skipped (unvirtualized build)", + }, fixture: summary, runProtocol: { warmups: budgets.warmupRuns,