Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/studio/src/hooks/useStudioTestHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/hooks/useStudioTestHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
115 changes: 115 additions & 0 deletions packages/studio/src/player/components/Timeline.virtualization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[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<void>((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<HTMLElement>("[data-timeline-scroll-viewport]");
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
await act(async () => {
scroller?.dispatchEvent(new Event("scroll"));
await new Promise<void>((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<HTMLElement>("[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<void>((resolve) => requestAnimationFrame(() => resolve()));
});

expect(scroller.scrollTop).toBe(400);
} finally {
dispose();
}
});
});
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>;
Expand Down
Loading
Loading