Files
hyperframes/packages/studio/src/player/components/Timeline.virtualization.test.tsx
T
Miguel Ángel c6925e471a feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
2026-07-31 19:43:47 +02:00

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class MockResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
this.callback(
[
{
target,
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
} as unknown as ResizeObserverEntry,
],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
}
const originalResizeObserver = globalThis.ResizeObserver;
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
let clientWidth = 900;
let clientHeight = 240;
beforeAll(() => {
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get: () => clientWidth,
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get: () => clientHeight,
});
});
beforeEach(() => {
clientWidth = 900;
clientHeight = 240;
});
afterAll(() => {
vi.unstubAllEnvs();
globalThis.ResizeObserver = originalResizeObserver;
if (originalClientWidth)
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
if (originalClientHeight)
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
document.body.innerHTML = "";
});
/**
* The virtualized list only mounts rows/clips after its ResizeObserver and the
* follow-up layout effect have both flushed, which is more than one React tick.
* A fixed number of flushes is a coin flip once the rest of the suite is
* competing for workers, so wait for the DOM the assertions actually need.
*/
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
for (let attempt = 0; attempt < tries; attempt++) {
if (predicate()) return;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
}
async function advanceFrame(): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
async function mountTimeline(element: React.ReactElement) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(element));
await act(async () => {});
return { host, root };
}
async function dispatchScroll(scroller: HTMLElement): Promise<void> {
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
function clipsByTrack(count: number, duration = 1, includeLabels = false) {
return Array.from({ length: count }, (_, track) => ({
id: `clip-${track}`,
...(includeLabels ? { label: `Clip ${track}` } : {}),
tag: "div",
start: 0,
duration,
track,
}));
}
async function scrollTimelineHorizontally(
scroller: HTMLElement,
scrollLeft: number,
): Promise<void> {
scroller.scrollLeft = scrollLeft;
await dispatchScroll(scroller);
}
// These tests mount 500-10,000 timeline elements and settle the virtualizer,
// which lands right on the 5s default once the rest of the suite is competing
// for workers. The generous ceiling is a flake guard, not an expected runtime.
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
clientWidth = 0;
clientHeight = 0;
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: clipsByTrack(10_000),
});
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
const rows = host.querySelectorAll('[role="listitem"]');
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
act(() => root.unmount());
usePlayerStore.getState().reset();
});
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
selectedElementId: "clip-0",
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
});
const { host, root } = await mountTimeline(
React.createElement(Timeline, {
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
}),
);
try {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
expect(scroller).not.toBeNull();
expect(clip).not.toBeNull();
expect(clip?.title).toBe("Clip 0 • 0.0s 10.0s");
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
if (scroller) await dispatchScroll(scroller);
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
} finally {
act(() => root.unmount());
usePlayerStore.getState().reset();
}
});
it("mounts a bounded list range over the full geometry height", async () => {
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("./timelineLayout"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: clipsByTrack(1_000),
});
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 3 }));
await settleUntil(
() =>
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
0,
);
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
expect(list?.parentElement?.style.height).toBe(
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
);
const firstRow = rows[0] as HTMLElement;
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
expect(focusedControl).not.toBeNull();
act(() => focusedControl?.focus());
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) {
scroller.scrollTop = 500 * 48;
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
});
}
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);
act(() => root.unmount());
usePlayerStore.getState().reset();
});
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("../lib/timelineViewportBudgets"),
]);
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
selectedElementIds: new Set(["clip-490"]),
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
// fallow-ignore-next-line code-duplication
elements: Array.from({ length: 500 }, (_, index) => ({
id: `clip-${index}`,
tag: "div",
start: index * 2,
duration: 1,
track: 0,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
await act(async () => {});
await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
expect(initialClips.length).toBeGreaterThan(1);
expect(initialClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(initialGridCells.length).toBeLessThan(100);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) await scrollTimelineHorizontally(scroller, 8_000);
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
expect(scrolledClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
await advanceFrame();
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
expect(document.activeElement).toBe(focusedClip);
await advanceFrame();
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
expect(document.activeElement).toBe(focusedClip);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
if (scroller) await scrollTimelineHorizontally(scroller, 0);
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
expect(document.activeElement).toBe(focusedClip);
act(() => root.unmount());
usePlayerStore.getState().reset();
});
});
/**
* 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() {
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: clipsByTrack(40, 10, true),
});
const { host, root } = await mountTimeline(
React.createElement(Timeline, {
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
}),
);
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);
if (scroller) await dispatchScroll(scroller);
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"]');
if (scroller) await dispatchScroll(scroller);
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 dispatchScroll(scroller);
expect(scroller.scrollTop).toBe(400);
} finally {
dispose();
}
});
});