From 6b98c8d41aeea4d70deee6eb0dbbe544d8d87291 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 31 Jul 2026 19:00:57 +0200 Subject: [PATCH 1/2] feat(studio): enable timeline virtualization by default --- .github/workflows/ci.yml | 93 +++++++++++-------- packages/studio/package.json | 2 +- .../src/player/components/Timeline.test.ts | 4 + .../Timeline.virtualization.test.tsx | 6 +- .../timelineRowVirtualizationFlag.test.ts | 23 +++++ .../timelineRowVirtualizationFlag.ts | 7 +- .../lib/timelineViewportBudgets.test.ts | 2 + .../src/player/lib/timelineViewportBudgets.ts | 14 ++- .../tests/e2e/timeline-virtualization.mjs | 34 ++++--- 9 files changed, 124 insertions(+), 61 deletions(-) create mode 100644 packages/studio/src/player/components/timelineRowVirtualizationFlag.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index becb5b7109..1d09091e03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -533,55 +533,68 @@ jobs: run: | set -euo pipefail - # Two servers, because row virtualization is read from import.meta.env - # at module load: one process cannot serve both builds. + SERVER_PID="" + stop_server() { + if [[ -n "$SERVER_PID" ]]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" + fi + } + wait_for_server() { + local port="$1" + for i in $(seq 1 60); do + if curl -sf "http://localhost:${port}/" >/dev/null 2>&1; then return 0; fi + sleep 1 + done + echo "FAIL: studio dev server did not start on port ${port}" + return 1 + } + trap stop_server EXIT + + # Run one server at a time so the measured browser never competes with + # a second Vite module graph on the shared runner. bun run --cwd packages/studio dev -- --port 5313 --strictPort & - DEFAULT_PID=$! - VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=1 \ - bun run --cwd packages/studio dev -- --port 5314 --strictPort & - VIRTUALIZED_PID=$! - trap 'kill $DEFAULT_PID $VIRTUALIZED_PID 2>/dev/null || true' EXIT - - for i in $(seq 1 60); do - if curl -sf http://localhost:5313/ >/dev/null 2>&1 \ - && curl -sf http://localhost:5314/ >/dev/null 2>&1; then break; fi - sleep 1 - done - if ! curl -sf http://localhost:5313/ >/dev/null 2>&1 \ - || ! curl -sf http://localhost:5314/ >/dev/null 2>&1; then - echo "FAIL: studio dev servers did not start" - exit 1 + SERVER_PID=$! + DEFAULT_STATUS=0 + if wait_for_server 5313; then + STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \ + TIMELINE_ROW_VIRTUALIZATION=on \ + TIMELINE_ELEMENT_COUNT=50000 \ + TIMELINE_TIER=ci \ + node packages/studio/tests/e2e/timeline-virtualization.mjs \ + | tee /tmp/timeline-gate-default.json \ + || DEFAULT_STATUS=$? + else + DEFAULT_STATUS=1 fi + stop_server - # The default build first. It is the one users get, and the arm that - # caught the regression this gate exists for. - # Capture both statuses so either failure still leaves two evidence files. - DEFAULT_STATUS=0 - STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \ - TIMELINE_ROW_VIRTUALIZATION=off \ - TIMELINE_ELEMENT_COUNT=1000 \ - TIMELINE_TIER=ci \ - node packages/studio/tests/e2e/timeline-virtualization.mjs \ - | tee /tmp/timeline-gate-default.json \ - || DEFAULT_STATUS=$? - - VIRTUALIZED_STATUS=0 - STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \ - TIMELINE_ROW_VIRTUALIZATION=on \ - TIMELINE_ELEMENT_COUNT=50000 \ - TIMELINE_TIER=ci \ - node packages/studio/tests/e2e/timeline-virtualization.mjs \ - | tee /tmp/timeline-gate-virtualized.json \ - || VIRTUALIZED_STATUS=$? + VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \ + bun run --cwd packages/studio dev -- --port 5314 --strictPort & + SERVER_PID=$! + DISABLED_STATUS=0 + if wait_for_server 5314; then + STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \ + TIMELINE_ROW_VIRTUALIZATION=off \ + TIMELINE_ELEMENT_COUNT=1000 \ + TIMELINE_TIER=ci \ + node packages/studio/tests/e2e/timeline-virtualization.mjs \ + | tee /tmp/timeline-gate-disabled.json \ + || DISABLED_STATUS=$? + else + DISABLED_STATUS=1 + fi + stop_server { echo "### Timeline viewport gate" echo "- Default arm exit: ${DEFAULT_STATUS}" - echo "- Virtualized arm exit: ${VIRTUALIZED_STATUS}" + echo "- Explicitly disabled arm exit: ${DISABLED_STATUS}" } >> "$GITHUB_STEP_SUMMARY" - if (( DEFAULT_STATUS != 0 || VIRTUALIZED_STATUS != 0 )); then - echo "FAIL: default=${DEFAULT_STATUS}, virtualized=${VIRTUALIZED_STATUS}" + if (( DEFAULT_STATUS != 0 || DISABLED_STATUS != 0 )); then + echo "FAIL: default=${DEFAULT_STATUS}, disabled=${DISABLED_STATUS}" exit 1 fi - name: Upload gate evidence diff --git a/packages/studio/package.json b/packages/studio/package.json index 8873e51995..a0f8f1fcc7 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -52,7 +52,7 @@ "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", - "test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs" + "test:timeline-default": "bun run test:timeline-virtualization" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index f415c9fce0..974441f6d3 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -41,6 +41,10 @@ import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; +vi.mock("./timelineRowVirtualizationFlag", () => ({ + STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false, +})); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx index 8772785ac1..b9a6c9e1a1 100644 --- a/packages/studio/src/player/components/Timeline.virtualization.test.tsx +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -307,9 +307,9 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => { }); /** - * 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. + * The rollback build mounts every clip, so the scroll-time concessions + * windowing makes are pure cost there. This block pins that explicit fallback + * to doing no per-frame work while a gesture runs. */ describe("Timeline without row virtualization", { timeout: 30_000 }, () => { async function renderUnvirtualizedTimeline() { diff --git a/packages/studio/src/player/components/timelineRowVirtualizationFlag.test.ts b/packages/studio/src/player/components/timelineRowVirtualizationFlag.test.ts new file mode 100644 index 0000000000..039d7608f4 --- /dev/null +++ b/packages/studio/src/player/components/timelineRowVirtualizationFlag.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("timeline row virtualization flag", () => { + it("enables virtualization by default", async () => { + const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } = + await import("./timelineRowVirtualizationFlag"); + + expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(true); + }); + + it("keeps an explicit rollback path", async () => { + vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0"); + const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } = + await import("./timelineRowVirtualizationFlag"); + + expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(false); + }); +}); diff --git a/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts b/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts index b6239fa753..322028f5cf 100644 --- a/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts +++ b/packages/studio/src/player/components/timelineRowVirtualizationFlag.ts @@ -1,11 +1,10 @@ /** - * Row virtualization opt-in. Disabled until horizontal windowing and stable - * gesture lifetime land. + * Row virtualization is the product default. Setting the environment flag to + * "0" keeps one explicit rollback path for comparisons and emergencies. * * 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"; + import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED !== "0"; diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts index 7acc87c93c..5689b5609a 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts @@ -23,6 +23,7 @@ describe("timeline viewport budgets", () => { constrainedLongTaskLimitMs: 300, posterCoverageRatio: 0.9, supportedFixtureFallbackRatio: 0.02, + scrollSamplesPerRun: 21, warmupRuns: 3, measuredRuns: 5, requiredPassingRuns: 4, @@ -51,6 +52,7 @@ describe("timeline viewport budgets", () => { [{ requiredPassingRuns: 0 }, "requiredPassingRuns"], [{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"], [{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"], + [{ scrollSamplesPerRun: 19 }, "scrollSamplesPerRun"], [{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"], ] as const)("rejects an invalid override %#", (overrides, message) => { expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message); diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.ts b/packages/studio/src/player/lib/timelineViewportBudgets.ts index 6d88a20c2a..483a936b5d 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts @@ -40,6 +40,7 @@ export interface TimelineViewportBudgets { richPreviewP95Ms: number; constrainedRichPreviewP95Ms: number; supportedFixtureFallbackRatio: number; + scrollSamplesPerRun: number; warmupRuns: number; measuredRuns: number; requiredPassingRuns: number; @@ -95,6 +96,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly = Obje richPreviewP95Ms: 750, constrainedRichPreviewP95Ms: 1_200, supportedFixtureFallbackRatio: 0.02, + scrollSamplesPerRun: 21, warmupRuns: 3, measuredRuns: 5, requiredPassingRuns: 4, @@ -113,11 +115,21 @@ export function resolveTimelineViewportBudgets( assertValidBudget(name as keyof TimelineViewportBudgets, value); } const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides }; - for (const name of ["warmupRuns", "measuredRuns", "requiredPassingRuns"] as const) { + for (const name of [ + "scrollSamplesPerRun", + "warmupRuns", + "measuredRuns", + "requiredPassingRuns", + ] as const) { if (!Number.isInteger(resolved[name])) { throw new RangeError(`Timeline viewport budget ${name} must be an integer`); } } + if (resolved.scrollSamplesPerRun < 20) { + throw new RangeError( + "Timeline viewport budget scrollSamplesPerRun must be at least 20 for p95", + ); + } if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) { throw new RangeError( "Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero", diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs index 99a55f540c..d27d962320 100644 --- a/packages/studio/tests/e2e/timeline-virtualization.mjs +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -15,11 +15,9 @@ * recorded on. Throttling it further would measure the throttle, not the build. * * 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. + * "on", the product default. 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"; @@ -30,7 +28,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 ROW_VIRTUALIZATION = process.env.TIMELINE_ROW_VIRTUALIZATION || "on"; const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR ? Number(process.env.TIMELINE_CHROME_MAJOR) : null; @@ -110,6 +108,7 @@ async function collectRun(page, injectedLongTaskMs = 0) { return { interactionP95Ms: percentileInPage(interactions, 0.95), frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95), + scrollSampleCount: interactions.length, longestTaskMs: Math.max(0, ...longTasks), scrollWidth: scroller.scrollWidth, scrollHeight: scroller.scrollHeight, @@ -166,7 +165,10 @@ async function collectRun(page, injectedLongTaskMs = 0) { const interactions = []; const frameIntervals = []; const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); - for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) { + const ratios = [0, 0.25, 0.5, 0.75, 1, 0.5, 0]; + const sampleCount = window.__studioTest.timelineViewportBudgets.scrollSamplesPerRun; + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const ratio = ratios[sampleIndex % ratios.length]; const started = performance.now(); timelineScroller.scrollLeft = Math.round( (timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio, @@ -184,7 +186,7 @@ async function collectRun(page, injectedLongTaskMs = 0) { }, injectedLongTaskMs); } -async function assertLongTaskCapture(browser, longTaskLimitMs) { +async function assertLongTaskCapture(browser, longTaskLimitMs, scrollSamplesPerRun) { const page = await browser.newPage(); const injectedDurationMs = longTaskLimitMs + 25; try { @@ -195,9 +197,12 @@ async function assertLongTaskCapture(browser, longTaskLimitMs) { `); - await page.evaluate(() => { - window.__studioTest = { readTimelinePerformanceDiagnostics: () => ({}) }; - }); + await page.evaluate((sampleCount) => { + window.__studioTest = { + timelineViewportBudgets: { scrollSamplesPerRun: sampleCount }, + readTimelinePerformanceDiagnostics: () => ({}), + }; + }, scrollSamplesPerRun); const probe = await collectRun(page, injectedDurationMs); if (probe.longestTaskMs <= longTaskLimitMs) { throw new Error( @@ -286,7 +291,11 @@ try { const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets); const longTaskLimitMs = TIER === "primary" ? budgets.longTaskLimitMs : budgets.constrainedLongTaskLimitMs; - const longTaskObserverProbe = await assertLongTaskCapture(browser, longTaskLimitMs); + const longTaskObserverProbe = await assertLongTaskCapture( + browser, + longTaskLimitMs, + budgets.scrollSamplesPerRun, + ); const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE); const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); @@ -376,6 +385,7 @@ try { }, fixture: summary, runProtocol: { + scrollSamplesPerRun: budgets.scrollSamplesPerRun, warmups: budgets.warmupRuns, measured: budgets.measuredRuns, requiredPassing: budgets.requiredPassingRuns, From a18831d24bdec5880537d3b051cdf45b9862dd03 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 31 Jul 2026 19:27:08 +0200 Subject: [PATCH 2/2] fix(ci): measure timeline performance in production React --- .github/workflows/ci.yml | 10 ++++-- .../studio/src/hooks/studioTestMode.test.ts | 35 +++++++++++++++++++ packages/studio/src/hooks/studioTestMode.ts | 23 ++++++++++++ .../src/hooks/useStudioTestHooks.test.tsx | 1 + .../studio/src/hooks/useStudioTestHooks.ts | 11 +++--- .../tests/e2e/timeline-virtualization.mjs | 11 ++++++ 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 packages/studio/src/hooks/studioTestMode.test.ts create mode 100644 packages/studio/src/hooks/studioTestMode.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d09091e03..110457f1cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -553,8 +553,11 @@ jobs: trap stop_server EXIT # Run one server at a time so the measured browser never competes with - # a second Vite module graph on the shared runner. - bun run --cwd packages/studio dev -- --port 5313 --strictPort & + # a second Vite module graph on the shared runner. The development + # server supplies the fixture API; production React matches shipped + # rendering behavior, and the gate asserts that runtime before timing. + NODE_ENV=production \ + bun run --cwd packages/studio dev -- --port 5313 --strictPort & SERVER_PID=$! DEFAULT_STATUS=0 if wait_for_server 5313; then @@ -570,7 +573,8 @@ jobs: fi stop_server - VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \ + NODE_ENV=production \ + VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \ bun run --cwd packages/studio dev -- --port 5314 --strictPort & SERVER_PID=$! DISABLED_STATUS=0 diff --git a/packages/studio/src/hooks/studioTestMode.test.ts b/packages/studio/src/hooks/studioTestMode.test.ts new file mode 100644 index 0000000000..1569433d1c --- /dev/null +++ b/packages/studio/src/hooks/studioTestMode.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("Studio test mode", () => { + it("exposes hooks in the normal development runtime", async () => { + const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode"); + + expect(STUDIO_RUNTIME_MODE).toBe("development"); + expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true); + }); + + it("keeps hooks on the development server while it uses production React", async () => { + vi.stubEnv("DEV", false); + vi.stubEnv("MODE", "development"); + + const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode"); + + expect(STUDIO_RUNTIME_MODE).toBe("production"); + expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true); + }); + + it("keeps test hooks out of an ordinary production build", async () => { + vi.stubEnv("DEV", false); + vi.stubEnv("MODE", "production"); + + const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode"); + + expect(STUDIO_RUNTIME_MODE).toBe("production"); + expect(STUDIO_TEST_HOOKS_ENABLED).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/studioTestMode.ts b/packages/studio/src/hooks/studioTestMode.ts new file mode 100644 index 0000000000..1bd4fd8b0e --- /dev/null +++ b/packages/studio/src/hooks/studioTestMode.ts @@ -0,0 +1,23 @@ +export type StudioRuntimeMode = "development" | "production"; + +function readStudioImportMetaEnv(): ImportMetaEnv | undefined { + try { + return import.meta.env; + } catch { + return undefined; + } +} + +const studioImportMetaEnv = readStudioImportMetaEnv(); + +/** + * Test hooks belong to Vite's development server, even when that server uses + * production React for performance measurement. A production build has neither + * DEV nor the development server mode, so the API stays out of shipped assets. + */ +export const STUDIO_RUNTIME_MODE: StudioRuntimeMode = studioImportMetaEnv?.DEV + ? "development" + : "production"; + +export const STUDIO_TEST_HOOKS_ENABLED = + studioImportMetaEnv?.DEV === true || studioImportMetaEnv?.MODE === "development"; diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index 816aa4f1b4..b1da619c93 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -85,6 +85,7 @@ describe("timeline performance fixture", () => { const api = window.__studioTest; expect(api).toBeDefined(); if (!api) throw new Error("Expected dev Studio test API"); + expect(api.runtimeMode).toBe("development"); let notifications = 0; usePlayerStore.setState({ isPlaying: true, diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 88d4f1ca84..8e1d4489d5 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -12,6 +12,7 @@ import { type TimelinePerformanceFixtureSummary, } from "../player/lib/timelinePerformanceFixture"; import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets"; +import { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } from "./studioTestMode"; interface StudioTestHookDeps { previewIframeRef: React.MutableRefObject; @@ -23,6 +24,7 @@ interface StudioTestHookDeps { } interface StudioTestApi { + runtimeMode: typeof STUDIO_RUNTIME_MODE; selectByDomId: (id: string) => Promise; loadTimelinePerformanceFixture: ( spec: TimelinePerformanceFixtureSpec, @@ -54,14 +56,9 @@ export function useStudioTestHooks({ }: StudioTestHookDeps): void { // eslint-disable-next-line no-restricted-syntax useEffect(() => { - let isDev = false; - try { - isDev = import.meta.env.DEV === true; - } catch { - isDev = false; - } - if (!isDev || typeof window === "undefined") return; + if (!STUDIO_TEST_HOOKS_ENABLED || typeof window === "undefined") return; const api: StudioTestApi = { + runtimeMode: STUDIO_RUNTIME_MODE, selectByDomId: async (id: string): Promise => { const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null; if (!element) return false; diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs index d27d962320..7795bdbd95 100644 --- a/packages/studio/tests/e2e/timeline-virtualization.mjs +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -13,6 +13,8 @@ * shared runner: no emulation, but the constrained budgets, because a hosted * runner is already slower and noisier than the machine the strict numbers were * recorded on. Throttling it further would measure the throttle, not the build. + * CI also requires production React and reports the observed runtime so Vite's + * development-only checks can never contaminate the shipped-code measurement. * * TIMELINE_ROW_VIRTUALIZATION selects which build is under test and defaults to * "on", the product default. The script asserts the configuration it observes @@ -284,6 +286,14 @@ try { ); await waitForStudioTestHookSettle(page); + const runtimeMode = await page.evaluate(() => window.__studioTest.runtimeMode); + if (TIER === "ci" && runtimeMode !== "production") { + throw new Error( + `Timeline CI must measure the production React runtime, received ${runtimeMode}. ` + + "Start the Studio development server with NODE_ENV=production.", + ); + } + await loadFixtureAndWait(page, 1_000, PROFILE); await client.send("HeapProfiler.collectGarbage"); const baselineHeapBytes = await collectHeapBytes(client); @@ -374,6 +384,7 @@ try { deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, cpuThrottleRate: TIER === "low-resource" ? 4 : 1, tier: TIER, + runtimeMode, longTaskObserverProbe, rowVirtualization: ROW_VIRTUALIZATION, observedClipRootsAtLoad: observedClipRoots,