Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 58 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -533,55 +533,72 @@ 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.
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=""
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. 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
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=$?
NODE_ENV=production \
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
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions packages/studio/src/hooks/studioTestMode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions packages/studio/src/hooks/studioTestMode.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions packages/studio/src/hooks/useStudioTestHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 4 additions & 7 deletions packages/studio/src/hooks/useStudioTestHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLIFrameElement | null>;
Expand All @@ -23,6 +24,7 @@ interface StudioTestHookDeps {
}

interface StudioTestApi {
runtimeMode: typeof STUDIO_RUNTIME_MODE;
selectByDomId: (id: string) => Promise<boolean>;
loadTimelinePerformanceFixture: (
spec: TimelinePerformanceFixtureSpec,
Expand Down Expand Up @@ -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<boolean> => {
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
if (!element) return false;
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("timeline viewport budgets", () => {
constrainedLongTaskLimitMs: 300,
posterCoverageRatio: 0.9,
supportedFixtureFallbackRatio: 0.02,
scrollSamplesPerRun: 21,
warmupRuns: 3,
measuredRuns: 5,
requiredPassingRuns: 4,
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion packages/studio/src/player/lib/timelineViewportBudgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface TimelineViewportBudgets {
richPreviewP95Ms: number;
constrainedRichPreviewP95Ms: number;
supportedFixtureFallbackRatio: number;
scrollSamplesPerRun: number;
warmupRuns: number;
measuredRuns: number;
requiredPassingRuns: number;
Expand Down Expand Up @@ -95,6 +96,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
richPreviewP95Ms: 750,
constrainedRichPreviewP95Ms: 1_200,
supportedFixtureFallbackRatio: 0.02,
scrollSamplesPerRun: 21,
warmupRuns: 3,
measuredRuns: 5,
requiredPassingRuns: 4,
Expand All @@ -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",
Expand Down
Loading
Loading