diff --git a/packages/studio/package.json b/packages/studio/package.json index 17fb6370c2..e4dd16df51 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 5537a5d1ec..db0d3da7e6 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -529,7 +529,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..bdcf26f293 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,