Skip to content

Commit 5d7f12d

Browse files
committed
perf(studio): define timeline viewport budgets and fixtures
1 parent 2469850 commit 5d7f12d

7 files changed

Lines changed: 691 additions & 0 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// @vitest-environment happy-dom
2+
import { act } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { usePlayerStore } from "../player/store/playerStore";
6+
import {
7+
createTimelinePerformanceFixture,
8+
type TimelinePerformanceFixtureProfile,
9+
} from "../player/lib/timelinePerformanceFixture";
10+
import { useStudioTestHooks } from "./useStudioTestHooks";
11+
12+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
13+
14+
const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [
15+
"dense-short",
16+
"long-overlap",
17+
"keyframe-heavy-expanded",
18+
"composition-heavy",
19+
"remote-unsupported",
20+
];
21+
22+
function Probe(): null {
23+
useStudioTestHooks({
24+
previewIframeRef: { current: null },
25+
buildDomSelectionFromTarget: vi.fn(),
26+
applyDomSelection: vi.fn(),
27+
});
28+
return null;
29+
}
30+
31+
describe("timeline performance fixture", () => {
32+
afterEach(() => {
33+
window.__studioTest = undefined;
34+
usePlayerStore.getState().reset();
35+
});
36+
37+
it("generates stable 50k identities, counts, distribution, and duration", () => {
38+
const first = createTimelinePerformanceFixture({
39+
elementCount: 50_000,
40+
profile: "dense-short",
41+
});
42+
const second = createTimelinePerformanceFixture({
43+
elementCount: 50_000,
44+
profile: "dense-short",
45+
});
46+
47+
expect(first.summary).toEqual(second.summary);
48+
expect(first.summary).toEqual({
49+
elementCount: 50_000,
50+
profile: "dense-short",
51+
duration: 120,
52+
trackCount: 1_000,
53+
keyframedElementCount: 0,
54+
expandedElementCount: 0,
55+
});
56+
expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000);
57+
const perTrack = new Map<number, number>();
58+
for (const element of first.elements) {
59+
perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1);
60+
}
61+
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
62+
expect(first.elements.slice(0, 3)).toEqual(second.elements.slice(0, 3));
63+
expect(first.elements.at(-1)).toEqual(second.elements.at(-1));
64+
});
65+
66+
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
67+
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
68+
expect(fixture.elements).toHaveLength(1_000);
69+
expect(fixture.summary.elementCount).toBe(1_000);
70+
expect(fixture.summary.duration).toBeGreaterThan(0);
71+
expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000);
72+
if (profile === "keyframe-heavy-expanded") {
73+
expect(fixture.keyframeCache.size).toBe(1_000);
74+
expect(fixture.gsapAnimations.size).toBe(1_000);
75+
expect(fixture.expandedClipIds.size).toBe(1_000);
76+
}
77+
});
78+
79+
it("publishes one dev-only loader that replaces fixture state atomically", () => {
80+
const host = document.createElement("div");
81+
const root = createRoot(host);
82+
act(() => root.render(<Probe />));
83+
const api = window.__studioTest;
84+
expect(api).toBeDefined();
85+
if (!api) throw new Error("Expected dev Studio test API");
86+
let notifications = 0;
87+
const unsubscribe = usePlayerStore.subscribe(() => {
88+
notifications += 1;
89+
});
90+
91+
const summary = api.loadTimelinePerformanceFixture({
92+
elementCount: 1_000,
93+
profile: "keyframe-heavy-expanded",
94+
});
95+
96+
expect(summary.elementCount).toBe(1_000);
97+
expect(notifications).toBe(1);
98+
expect(usePlayerStore.getState()).toMatchObject({
99+
duration: 600,
100+
timelineReady: true,
101+
});
102+
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
103+
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
104+
unsubscribe();
105+
act(() => root.unmount());
106+
expect(window.__studioTest).toBeUndefined();
107+
});
108+
109+
it("does not mutate state when the fixture request is invalid", () => {
110+
const host = document.createElement("div");
111+
const root = createRoot(host);
112+
act(() => root.render(<Probe />));
113+
const api = window.__studioTest;
114+
if (!api) throw new Error("Expected dev Studio test API");
115+
const before = usePlayerStore.getState().elements;
116+
117+
expect(() =>
118+
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
119+
{ elementCount: 999, profile: "dense-short" },
120+
]),
121+
).toThrow("elementCount must be 1000 or 50000");
122+
expect(usePlayerStore.getState().elements).toBe(before);
123+
act(() => root.unmount());
124+
});
125+
});

packages/studio/src/hooks/useStudioTestHooks.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { useEffect } from "react";
22
import type { DomEditSelection } from "../components/editor/domEditing";
3+
import { usePlayerStore } from "../player/store/playerStore";
4+
import {
5+
readTimelinePerformanceDiagnostics,
6+
type TimelinePerformanceDiagnostics,
7+
} from "../player/lib/timelinePerformanceDiagnostics";
8+
import {
9+
createTimelinePerformanceFixture,
10+
type TimelinePerformanceFixtureSpec,
11+
type TimelinePerformanceFixtureSummary,
12+
} from "../player/lib/timelinePerformanceFixture";
13+
import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets";
314

415
interface StudioTestHookDeps {
516
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -12,6 +23,11 @@ interface StudioTestHookDeps {
1223

1324
interface StudioTestApi {
1425
selectByDomId: (id: string) => Promise<boolean>;
26+
loadTimelinePerformanceFixture: (
27+
spec: TimelinePerformanceFixtureSpec,
28+
) => TimelinePerformanceFixtureSummary;
29+
readTimelinePerformanceDiagnostics: () => Readonly<TimelinePerformanceDiagnostics>;
30+
timelineViewportBudgets: typeof TIMELINE_VIEWPORT_BUDGETS;
1531
}
1632

1733
declare global {
@@ -52,6 +68,26 @@ export function useStudioTestHooks({
5268
applyDomSelection(selection, { revealPanel: true });
5369
return true;
5470
},
71+
loadTimelinePerformanceFixture: (spec) => {
72+
const fixture = createTimelinePerformanceFixture(spec);
73+
usePlayerStore.setState({
74+
currentTime: 0,
75+
duration: fixture.summary.duration,
76+
timelineReady: true,
77+
zoomMode: "manual",
78+
manualZoomPercent: 2_000,
79+
elements: fixture.elements,
80+
selectedElementId: null,
81+
selectedElementIds: new Set(),
82+
selectedKeyframes: new Set(),
83+
keyframeCache: fixture.keyframeCache,
84+
gsapAnimations: fixture.gsapAnimations,
85+
expandedClipIds: fixture.expandedClipIds,
86+
});
87+
return fixture.summary;
88+
},
89+
readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(),
90+
timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS,
5591
};
5692
window.__studioTest = api;
5793
return () => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import {
4+
getTimelineResourceBudgetStatus,
5+
readTimelinePerformanceDiagnostics,
6+
resolveTimelineScrollStrategy,
7+
} from "./timelinePerformanceDiagnostics";
8+
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
9+
10+
describe("timeline performance diagnostics", () => {
11+
afterEach(() => {
12+
document.body.replaceChildren();
13+
});
14+
15+
it("reads mounted resources without mutating the timeline", () => {
16+
document.body.innerHTML = `
17+
<div aria-label="Timeline" data-timeline-scheduler-queued="3"
18+
data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
19+
<div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
20+
<div data-timeline-row><div data-clip="true"></div></div>
21+
<div data-timeline-grid-cell></div><div data-timeline-grid-cell></div>
22+
<div data-timeline-poster-state="ready"></div>
23+
<div data-timeline-poster-state="error"></div>
24+
</div>`;
25+
const before = document.body.innerHTML;
26+
27+
expect(readTimelinePerformanceDiagnostics()).toMatchObject({
28+
timelineRoots: 1,
29+
mountedRows: 2,
30+
mountedClipRoots: 3,
31+
maxMountedClipRootsInOneRow: 2,
32+
mountedTimeGridCells: 2,
33+
schedulerQueued: 3,
34+
schedulerActive: 2,
35+
cacheBytes: 4096,
36+
posterStates: { idle: 0, loading: 0, ready: 1, fallback: 0, error: 1 },
37+
});
38+
expect(document.body.innerHTML).toBe(before);
39+
});
40+
41+
it("returns the zero baseline after unmount or reset removes the DOM", () => {
42+
document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
43+
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
44+
45+
document.body.replaceChildren();
46+
47+
expect(readTimelinePerformanceDiagnostics()).toEqual({
48+
timelineRoots: 0,
49+
mountedRows: 0,
50+
mountedClipRoots: 0,
51+
maxMountedClipRootsInOneRow: 0,
52+
mountedTimeGridCells: 0,
53+
mountedTimelineDescendants: 0,
54+
schedulerQueued: 0,
55+
schedulerActive: 0,
56+
cacheBytes: 0,
57+
posterStates: { idle: 0, loading: 0, ready: 0, fallback: 0, error: 0 },
58+
});
59+
});
60+
61+
it("checks the DOM ceilings including the strict descendant boundary", () => {
62+
const budgets = resolveTimelineViewportBudgets({
63+
maxMountedClipRoots: 2,
64+
maxMountedClipRootsPerRow: 1,
65+
maxMountedTimelineDescendants: 4,
66+
});
67+
expect(
68+
getTimelineResourceBudgetStatus(
69+
{
70+
...readTimelinePerformanceDiagnostics(),
71+
mountedClipRoots: 2,
72+
maxMountedClipRootsInOneRow: 2,
73+
mountedTimelineDescendants: 4,
74+
},
75+
budgets,
76+
),
77+
).toEqual({ clipRoots: true, clipRootsPerRow: false, descendants: false });
78+
});
79+
80+
it("selects direct scrolling only through the configured safety envelope", () => {
81+
expect(resolveTimelineScrollStrategy(8_000_000)).toBe("direct");
82+
expect(resolveTimelineScrollStrategy(8_000_001)).toBe("segmented");
83+
expect(() => resolveTimelineScrollStrategy(Number.NaN)).toThrow("content width");
84+
});
85+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
2+
3+
export type TimelinePosterState = "idle" | "loading" | "ready" | "fallback" | "error";
4+
5+
export interface TimelinePerformanceDiagnostics {
6+
timelineRoots: number;
7+
mountedRows: number;
8+
mountedClipRoots: number;
9+
maxMountedClipRootsInOneRow: number;
10+
mountedTimeGridCells: number;
11+
mountedTimelineDescendants: number;
12+
schedulerQueued: number;
13+
schedulerActive: number;
14+
cacheBytes: number;
15+
posterStates: Readonly<Record<TimelinePosterState, number>>;
16+
}
17+
18+
export interface TimelineResourceBudgetStatus {
19+
clipRoots: boolean;
20+
clipRootsPerRow: boolean;
21+
descendants: boolean;
22+
}
23+
24+
function readNonNegativeNumber(value: string | undefined): number {
25+
const number = Number(value);
26+
return Number.isFinite(number) && number >= 0 ? number : 0;
27+
}
28+
29+
function countPosters(root: ParentNode): Readonly<Record<TimelinePosterState, number>> {
30+
const counts: Record<TimelinePosterState, number> = {
31+
idle: 0,
32+
loading: 0,
33+
ready: 0,
34+
fallback: 0,
35+
error: 0,
36+
};
37+
for (const node of root.querySelectorAll<HTMLElement>("[data-timeline-poster-state]")) {
38+
const state = node.dataset.timelinePosterState;
39+
if (state && state in counts) counts[state as TimelinePosterState] += 1;
40+
}
41+
return Object.freeze(counts);
42+
}
43+
44+
function maxClipsInOneRow(root: ParentNode): number {
45+
const byRow = new Map<Element | null, number>();
46+
for (const clip of root.querySelectorAll<HTMLElement>('[data-clip="true"]')) {
47+
const row = clip.closest("[data-timeline-row]");
48+
byRow.set(row, (byRow.get(row) ?? 0) + 1);
49+
}
50+
return Math.max(0, ...byRow.values());
51+
}
52+
53+
function sumDataAttribute(root: ParentNode, selector: string, dataKey: string): number {
54+
let total = 0;
55+
for (const node of root.querySelectorAll<HTMLElement>(selector)) {
56+
total += readNonNegativeNumber(node.dataset[dataKey]);
57+
}
58+
return total;
59+
}
60+
61+
/**
62+
* Read current timeline costs directly from the mounted DOM. No counters are
63+
* retained, so an unmount or project reset is reflected as a zero baseline on
64+
* the next read rather than depending on cleanup ordering.
65+
*/
66+
export function readTimelinePerformanceDiagnostics(
67+
root: ParentNode = document,
68+
): Readonly<TimelinePerformanceDiagnostics> {
69+
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
70+
let mountedTimelineDescendants = 0;
71+
for (const timelineRoot of timelineRoots) {
72+
mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
73+
}
74+
return Object.freeze({
75+
timelineRoots: timelineRoots.length,
76+
mountedRows: root.querySelectorAll("[data-timeline-row]").length,
77+
mountedClipRoots: root.querySelectorAll('[data-clip="true"]').length,
78+
maxMountedClipRootsInOneRow: maxClipsInOneRow(root),
79+
mountedTimeGridCells: root.querySelectorAll("[data-timeline-grid-cell]").length,
80+
mountedTimelineDescendants,
81+
schedulerQueued: sumDataAttribute(
82+
root,
83+
"[data-timeline-scheduler-queued]",
84+
"timelineSchedulerQueued",
85+
),
86+
schedulerActive: sumDataAttribute(
87+
root,
88+
"[data-timeline-scheduler-active]",
89+
"timelineSchedulerActive",
90+
),
91+
cacheBytes: sumDataAttribute(root, "[data-timeline-cache-bytes]", "timelineCacheBytes"),
92+
posterStates: countPosters(root),
93+
});
94+
}
95+
96+
export function getTimelineResourceBudgetStatus(
97+
diagnostics: TimelinePerformanceDiagnostics,
98+
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
99+
): Readonly<TimelineResourceBudgetStatus> {
100+
return Object.freeze({
101+
clipRoots: diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots,
102+
clipRootsPerRow: diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow,
103+
descendants: diagnostics.mountedTimelineDescendants < budgets.maxMountedTimelineDescendants,
104+
});
105+
}
106+
107+
export function resolveTimelineScrollStrategy(
108+
contentWidthPx: number,
109+
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
110+
): "direct" | "segmented" {
111+
if (!Number.isFinite(contentWidthPx) || contentWidthPx < 0) {
112+
throw new RangeError("Timeline content width must be a finite non-negative number");
113+
}
114+
return contentWidthPx <= budgets.directScrollSafetyPx ? "direct" : "segmented";
115+
}

0 commit comments

Comments
 (0)