Skip to content

Commit 2f24063

Browse files
committed
perf(studio): define timeline viewport budgets and fixtures
1 parent fbfffb1 commit 2f24063

10 files changed

Lines changed: 794 additions & 29 deletions
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 the expected dense-short 50k distribution", () => {
38+
const first = createTimelinePerformanceFixture({
39+
elementCount: 50_000,
40+
profile: "dense-short",
41+
});
42+
43+
expect(first.summary).toEqual({
44+
elementCount: 50_000,
45+
profile: "dense-short",
46+
duration: 120,
47+
trackCount: 1_000,
48+
keyframedElementCount: 0,
49+
expandedElementCount: 0,
50+
});
51+
expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000);
52+
const perTrack = new Map<number, number>();
53+
for (const element of first.elements) {
54+
perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1);
55+
}
56+
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
57+
});
58+
59+
it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
60+
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
61+
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
62+
expect(second).toEqual(first);
63+
});
64+
65+
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
66+
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
67+
expect(fixture.elements).toHaveLength(1_000);
68+
expect(fixture.summary.elementCount).toBe(1_000);
69+
expect(fixture.summary.duration).toBeGreaterThan(0);
70+
expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000);
71+
if (profile === "keyframe-heavy-expanded") {
72+
expect(fixture.keyframeCache.size).toBe(1_000);
73+
expect(fixture.gsapAnimations.size).toBe(1_000);
74+
expect(fixture.expandedClipIds.size).toBe(1_000);
75+
}
76+
});
77+
78+
it("publishes one dev-only loader that replaces fixture state atomically", () => {
79+
const host = document.createElement("div");
80+
const root = createRoot(host);
81+
act(() => root.render(<Probe />));
82+
const api = window.__studioTest;
83+
expect(api).toBeDefined();
84+
if (!api) throw new Error("Expected dev Studio test API");
85+
let notifications = 0;
86+
usePlayerStore.setState({
87+
isPlaying: true,
88+
requestedSeekTime: 42,
89+
clipRevealRequest: { elementId: "stale", nonce: 7 },
90+
clipManifest: [],
91+
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
92+
});
93+
const unsubscribe = usePlayerStore.subscribe(() => {
94+
notifications += 1;
95+
});
96+
97+
const summary = api.loadTimelinePerformanceFixture({
98+
elementCount: 1_000,
99+
profile: "keyframe-heavy-expanded",
100+
});
101+
102+
expect(summary.elementCount).toBe(1_000);
103+
expect(notifications).toBe(1);
104+
expect(usePlayerStore.getState()).toMatchObject({
105+
isPlaying: false,
106+
requestedSeekTime: null,
107+
clipRevealRequest: null,
108+
clipManifest: null,
109+
duration: 600,
110+
timelineReady: true,
111+
});
112+
expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0);
113+
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
114+
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
115+
unsubscribe();
116+
act(() => root.unmount());
117+
expect(window.__studioTest).toBeUndefined();
118+
});
119+
120+
it("does not mutate state when the fixture request is invalid", () => {
121+
const host = document.createElement("div");
122+
const root = createRoot(host);
123+
act(() => root.render(<Probe />));
124+
const api = window.__studioTest;
125+
if (!api) throw new Error("Expected dev Studio test API");
126+
const before = usePlayerStore.getState().elements;
127+
128+
expect(() =>
129+
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
130+
{ elementCount: 999, profile: "dense-short" },
131+
]),
132+
).toThrow("elementCount must be 1000 or 50000");
133+
expect(usePlayerStore.getState().elements).toBe(before);
134+
expect(() =>
135+
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
136+
{ elementCount: 1_000, profile: "constructor" },
137+
]),
138+
).toThrow("Unknown timeline performance fixture profile");
139+
act(() => root.unmount());
140+
});
141+
});

packages/studio/src/hooks/useStudioTestHooks.ts

Lines changed: 38 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 { createTimelineResetState, 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,28 @@ export function useStudioTestHooks({
5268
applyDomSelection(selection, { revealPanel: true });
5369
return true;
5470
},
71+
loadTimelinePerformanceFixture: (spec) => {
72+
const fixture = createTimelinePerformanceFixture(spec);
73+
usePlayerStore.setState({
74+
...createTimelineResetState(),
75+
currentTime: 0,
76+
duration: fixture.summary.duration,
77+
timelineReady: true,
78+
loopEnabled: false,
79+
zoomMode: "manual",
80+
manualZoomPercent: 2_000,
81+
elements: fixture.elements,
82+
selectedElementId: null,
83+
selectedElementIds: new Set(),
84+
selectedKeyframes: new Set(),
85+
keyframeCache: fixture.keyframeCache,
86+
gsapAnimations: fixture.gsapAnimations,
87+
expandedClipIds: fixture.expandedClipIds,
88+
});
89+
return fixture.summary;
90+
},
91+
readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(),
92+
timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS,
5593
};
5694
window.__studioTest = api;
5795
return () => {

packages/studio/src/player/components/TimelineLanes.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ describe("TimelineLanes track numbering", () => {
144144
});
145145

146146
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
147+
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
147148
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
148149
act(() => view.root.unmount());
149150
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function TimelineLanes({
150150
return (
151151
<div
152152
key={trackNum}
153+
data-timeline-row
153154
className="relative flex"
154155
style={{
155156
height: rowHeight,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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-clip="true"></div>
22+
<div data-timeline-grid-cell></div><div data-timeline-grid-cell></div>
23+
<div data-timeline-poster-state="ready"></div>
24+
<div data-timeline-poster-state="error"></div>
25+
<div data-timeline-poster-state="constructor"></div>
26+
</div>`;
27+
const before = document.body.innerHTML;
28+
29+
expect(readTimelinePerformanceDiagnostics()).toMatchObject({
30+
timelineRoots: 1,
31+
mountedRows: 2,
32+
mountedClipRoots: 4,
33+
maxMountedClipRootsInOneRow: 2,
34+
mountedTimeGridCells: 2,
35+
schedulerQueued: 3,
36+
schedulerActive: 2,
37+
cacheBytes: 4096,
38+
posterStates: { idle: 0, loading: 0, ready: 1, fallback: 0, error: 1 },
39+
});
40+
expect(document.body.innerHTML).toBe(before);
41+
});
42+
43+
it("returns the zero baseline after unmount or reset removes the DOM", () => {
44+
document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
45+
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
46+
47+
document.body.replaceChildren();
48+
49+
expect(readTimelinePerformanceDiagnostics()).toEqual({
50+
timelineRoots: 0,
51+
mountedRows: 0,
52+
mountedClipRoots: 0,
53+
maxMountedClipRootsInOneRow: 0,
54+
mountedTimeGridCells: 0,
55+
mountedTimelineDescendants: 0,
56+
schedulerQueued: 0,
57+
schedulerActive: 0,
58+
cacheBytes: 0,
59+
posterStates: { idle: 0, loading: 0, ready: 0, fallback: 0, error: 0 },
60+
});
61+
});
62+
63+
it("treats every DOM ceiling as inclusive", () => {
64+
const budgets = resolveTimelineViewportBudgets({
65+
maxMountedClipRoots: 2,
66+
maxMountedClipRootsPerRow: 1,
67+
maxMountedRows: 2,
68+
maxMountedTimelineDescendants: 4,
69+
});
70+
expect(
71+
getTimelineResourceBudgetStatus(
72+
{
73+
...readTimelinePerformanceDiagnostics(),
74+
mountedClipRoots: 2,
75+
maxMountedClipRootsInOneRow: 2,
76+
mountedTimelineDescendants: 4,
77+
},
78+
budgets,
79+
),
80+
).toEqual({
81+
timelineRoot: false,
82+
rows: true,
83+
clipRoots: true,
84+
clipRootsPerRow: false,
85+
descendants: true,
86+
});
87+
});
88+
89+
it("fails the resource status when the timeline is absent", () => {
90+
expect(getTimelineResourceBudgetStatus(readTimelinePerformanceDiagnostics())).toMatchObject({
91+
timelineRoot: false,
92+
});
93+
});
94+
95+
it("selects direct scrolling only through the configured safety envelope", () => {
96+
expect(resolveTimelineScrollStrategy(8_000_000)).toBe("direct");
97+
expect(resolveTimelineScrollStrategy(8_000_001)).toBe("segmented");
98+
expect(() => resolveTimelineScrollStrategy(Number.NaN)).toThrow("content width");
99+
});
100+
});

0 commit comments

Comments
 (0)