Skip to content

Commit 546751c

Browse files
committed
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.
1 parent 46c0d25 commit 546751c

11 files changed

Lines changed: 358 additions & 58 deletions

packages/studio/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@
4949
"build": "vite build && tsup",
5050
"typecheck": "tsc --noEmit",
5151
"test": "vitest run",
52-
"test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs",
52+
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
5353
"test:watch": "vitest",
54-
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
54+
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
55+
"test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs"
5556
},
5657
"dependencies": {
5758
"@codemirror/autocomplete": "^6.20.1",

packages/studio/src/hooks/useStudioTestHooks.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ describe("timeline performance fixture", () => {
119119
unsubscribe();
120120
act(() => root.unmount());
121121
expect(window.__studioTest).toBeUndefined();
122-
expect(hasTimelinePerformanceFixtureLease()).toBe(false);
122+
// The lease outlives the effect on purpose. Loading a fixture changes the
123+
// player state this effect depends on, so the effect tears down right after
124+
// the loader runs; releasing the lease there let live iframe discovery
125+
// overwrite the fixture before anything could measure it.
126+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
123127
});
124128

125129
it("does not mutate state when the fixture request is invalid", () => {

packages/studio/src/hooks/useStudioTestHooks.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ export function useStudioTestHooks({
9595
};
9696
window.__studioTest = api;
9797
return () => {
98-
setTimelinePerformanceFixtureLease(false);
98+
// The lease is deliberately NOT released here. Loading a fixture writes
99+
// player state, which changes this effect's dependency identities and
100+
// tears the effect down on the very next frame. Releasing on teardown
101+
// therefore revoked the lease moments after it was taken, and live iframe
102+
// discovery overwrote the fixture the loader had just installed. The
103+
// lease belongs to the fixture, and a page reload clears it.
99104
// delete, not `= undefined`: an own key holding undefined keeps
100105
// `"__studioTest" in window` true, which defeats feature detection.
101106
delete window.__studioTest;

packages/studio/src/player/components/Timeline.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,10 @@ export const Timeline = memo(function Timeline({
530530
blockedClipRef={blockedClipRef}
531531
suppressClickRef={suppressClickRef}
532532
scrollRef={scrollRef}
533-
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
533+
// Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
534+
renderClipContent={
535+
rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
536+
}
534537
renderClipOverlay={renderClipOverlay}
535538
playheadRef={playheadRef}
536539
onDrillDown={onDrillDown}

packages/studio/src/player/components/Timeline.virtualization.test.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,118 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
255255
usePlayerStore.getState().reset();
256256
});
257257
});
258+
259+
/**
260+
* The flag-off build is the one users get today. It mounts every clip, so the
261+
* scroll-time concessions windowing makes are pure cost there: this block pins
262+
* the timeline to doing no per-frame work at all while a gesture runs.
263+
*/
264+
describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
265+
async function renderUnvirtualizedTimeline() {
266+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
267+
vi.resetModules();
268+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
269+
import("./Timeline"),
270+
import("../store/playerStore"),
271+
]);
272+
usePlayerStore.setState({
273+
duration: 60,
274+
timelineReady: true,
275+
selectedElementId: "clip-0",
276+
elements: Array.from({ length: 40 }, (_, track) => ({
277+
id: `clip-${track}`,
278+
label: `Clip ${track}`,
279+
tag: "div",
280+
start: 0,
281+
duration: 10,
282+
track,
283+
})),
284+
});
285+
286+
const host = document.createElement("div");
287+
document.body.append(host);
288+
const root = createRoot(host);
289+
await act(async () =>
290+
root.render(
291+
React.createElement(Timeline, {
292+
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
293+
}),
294+
),
295+
);
296+
await act(async () => {});
297+
return {
298+
host,
299+
dispose: () => {
300+
act(() => root.unmount());
301+
usePlayerStore.getState().reset();
302+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
303+
vi.resetModules();
304+
},
305+
};
306+
}
307+
308+
it("mounts every clip rather than a window", async () => {
309+
const { host, dispose } = await renderUnvirtualizedTimeline();
310+
try {
311+
expect(host.querySelectorAll("[data-clip]").length).toBe(40);
312+
} finally {
313+
dispose();
314+
}
315+
});
316+
317+
it("keeps clip content mounted across a scroll gesture", async () => {
318+
const { host, dispose } = await renderUnvirtualizedTimeline();
319+
try {
320+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
321+
expect(scroller).not.toBeNull();
322+
const richBefore = host.querySelectorAll("[data-rich-content]").length;
323+
expect(richBefore).toBe(40);
324+
325+
await act(async () => {
326+
scroller?.dispatchEvent(new Event("scroll"));
327+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
328+
});
329+
330+
expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore);
331+
} finally {
332+
dispose();
333+
}
334+
});
335+
336+
it("does not swap clip content back in after the gesture settles", async () => {
337+
const { host, dispose } = await renderUnvirtualizedTimeline();
338+
try {
339+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
340+
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
341+
await act(async () => {
342+
scroller?.dispatchEvent(new Event("scroll"));
343+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
344+
});
345+
await act(async () => {
346+
await new Promise((resolve) => setTimeout(resolve, 150));
347+
});
348+
349+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
350+
expect(host.querySelectorAll("[data-rich-content]").length).toBe(40);
351+
} finally {
352+
dispose();
353+
}
354+
});
355+
356+
it("leaves the scroll position alone, so no snapshot round trip happens", async () => {
357+
const { host, dispose } = await renderUnvirtualizedTimeline();
358+
try {
359+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
360+
if (!scroller) throw new Error("Expected a timeline scroll viewport");
361+
scroller.scrollTop = 400;
362+
await act(async () => {
363+
scroller.dispatchEvent(new Event("scroll"));
364+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
365+
});
366+
367+
expect(scroller.scrollTop).toBe(400);
368+
} finally {
369+
dispose();
370+
}
371+
});
372+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Row virtualization opt-in. Disabled until horizontal windowing and stable
3+
* gesture lifetime land.
4+
*
5+
* It lives in its own module so the scroll-viewport hook can read it without
6+
* importing the virtualization hook that already imports the viewport snapshot
7+
* type back, which would close an import cycle.
8+
*/
9+
export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED =
10+
import.meta.env.DEV === true &&
11+
import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1";

packages/studio/src/player/components/useTimelineRowVirtualization.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
44
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
55
import type { TimelineRowGeometry } from "./timelineLayout";
66
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
7-
import {
8-
STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED,
9-
useTimelineVirtualRows,
10-
} from "./useTimelineVirtualRows";
7+
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
8+
import { useTimelineVirtualRows } from "./useTimelineVirtualRows";
119

1210
interface UseTimelineRowVirtualizationInput {
1311
scrollRef: RefObject<HTMLDivElement | null>;

0 commit comments

Comments
 (0)