Skip to content

Commit 13fcdc2

Browse files
committed
perf(studio): stabilize virtualized beat gestures
1 parent e509537 commit 13fcdc2

2 files changed

Lines changed: 584 additions & 35 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import type { TimelineElement } from "../store/playerStore";
7+
import { usePlayerStore } from "../store/playerStore";
8+
import { BeatStrip } from "./BeatStrip";
9+
10+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11+
12+
const roots: Root[] = [];
13+
const requestSeek = vi.fn();
14+
const commitBeatEdits = usePlayerStore.getState().commitBeatEdits;
15+
const commitBeatEditsSpy = vi.fn((...args: Parameters<typeof commitBeatEdits>) =>
16+
commitBeatEdits(...args),
17+
);
18+
const MUSIC_ELEMENT: TimelineElement = {
19+
id: "background-music",
20+
label: "Music",
21+
tag: "audio",
22+
src: "music.mp3",
23+
start: 0,
24+
duration: 10,
25+
track: 0,
26+
timelineRole: "music",
27+
};
28+
const BEAT_ANALYSIS = {
29+
beatTimes: [1, 3],
30+
beatStrengths: [0.5, 0.8],
31+
bpm: 120,
32+
bpmConfidence: "high" as const,
33+
channelData: null,
34+
sampleRate: 48_000,
35+
peak: 1,
36+
};
37+
38+
function pointerEvent(type: string, init: PointerEventInit): Event {
39+
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
40+
const event = new MouseEvent(type, init);
41+
Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 });
42+
return event;
43+
}
44+
45+
function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
46+
const viewport = document.createElement("div");
47+
viewport.dataset.timelineScrollViewport = "";
48+
Object.defineProperties(viewport, {
49+
clientWidth: { configurable: true, value: 500 },
50+
clientHeight: { configurable: true, value: 300 },
51+
scrollWidth: { configurable: true, value: 2_000 },
52+
scrollHeight: { configurable: true, value: 2_000 },
53+
});
54+
viewport.getBoundingClientRect = () =>
55+
({ left: 0, right: 1_000, top: 0, bottom: 500, width: 1_000, height: 500 }) as DOMRect;
56+
Object.assign(viewport, {
57+
setPointerCapture: vi.fn(),
58+
hasPointerCapture: vi.fn(() => true),
59+
releasePointerCapture: vi.fn(),
60+
});
61+
document.body.appendChild(viewport);
62+
const root = createRoot(viewport);
63+
roots.push(root);
64+
act(() => {
65+
root.render(
66+
<BeatStrip
67+
beatTimes={[1, 3]}
68+
beatStrengths={[0.5, 0.8]}
69+
pps={100}
70+
renderTimeRange={renderTimeRange}
71+
/>,
72+
);
73+
});
74+
return { root, viewport };
75+
}
76+
77+
function firstBeat(): HTMLDivElement {
78+
const beat = document.querySelector<HTMLDivElement>(
79+
'[title="Drag to move · double-click to delete"]',
80+
);
81+
if (!beat) throw new Error("Expected a beat handle");
82+
return beat;
83+
}
84+
85+
function startBeatDrag(clientX = 100, pointerId = 1): void {
86+
act(() => {
87+
firstBeat().dispatchEvent(
88+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY: 100, pointerId }),
89+
);
90+
});
91+
expect(usePlayerStore.getState().beatDragging).toBe(true);
92+
}
93+
94+
function releaseBeatDrag(clientX = 140, pointerId = 1): void {
95+
act(() => {
96+
window.dispatchEvent(
97+
pointerEvent("pointerup", { bubbles: true, clientX, clientY: 100, pointerId }),
98+
);
99+
});
100+
}
101+
102+
function expectCancelledBeatDrag(): void {
103+
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
104+
expect(usePlayerStore.getState().beatDragging).toBe(false);
105+
}
106+
107+
function expectCommittedBeatAt(time: number): void {
108+
expect(commitBeatEditsSpy).toHaveBeenCalledExactlyOnceWith(expect.anything(), "move beat");
109+
expect(usePlayerStore.getState().beatEdits?.added[0]?.time).toBe(time);
110+
}
111+
112+
beforeEach(() => {
113+
commitBeatEditsSpy.mockClear();
114+
requestSeek.mockReset();
115+
usePlayerStore.setState({
116+
timelineSessionEpoch: 1,
117+
timelineProjectId: "project-a",
118+
beatDragging: false,
119+
requestSeek,
120+
elements: [MUSIC_ELEMENT],
121+
beatAnalysis: BEAT_ANALYSIS,
122+
beatEdits: null,
123+
commitBeatEdits: commitBeatEditsSpy,
124+
});
125+
});
126+
127+
afterEach(() => {
128+
act(() => window.dispatchEvent(new Event("blur")));
129+
for (const root of roots.splice(0)) act(() => root.unmount());
130+
document.body.innerHTML = "";
131+
vi.unstubAllGlobals();
132+
});
133+
134+
describe("BeatStrip gesture ownership", () => {
135+
it("commits once after its source row unmounts", () => {
136+
const { root } = mountBeatStrip();
137+
startBeatDrag();
138+
act(() => root.render(null));
139+
expect(usePlayerStore.getState().beatDragging).toBe(true);
140+
141+
act(() => {
142+
window.dispatchEvent(
143+
pointerEvent("pointermove", {
144+
bubbles: true,
145+
clientX: 140,
146+
clientY: 100,
147+
pointerId: 1,
148+
}),
149+
);
150+
window.dispatchEvent(
151+
pointerEvent("pointerup", {
152+
bubbles: true,
153+
clientX: 140,
154+
clientY: 100,
155+
pointerId: 1,
156+
}),
157+
);
158+
window.dispatchEvent(
159+
pointerEvent("pointerup", {
160+
bubbles: true,
161+
clientX: 160,
162+
clientY: 100,
163+
pointerId: 1,
164+
}),
165+
);
166+
});
167+
168+
expectCommittedBeatAt(1.4);
169+
expect(usePlayerStore.getState().beatDragging).toBe(false);
170+
});
171+
172+
it("includes viewport scrolling in the final beat time", () => {
173+
const { viewport } = mountBeatStrip();
174+
startBeatDrag(100);
175+
viewport.scrollLeft = 50;
176+
177+
releaseBeatDrag(110);
178+
expectCommittedBeatAt(1.6);
179+
});
180+
181+
it("cancels without mutation on pointer cancel, Escape, and project switch", () => {
182+
mountBeatStrip();
183+
startBeatDrag();
184+
act(() => {
185+
window.dispatchEvent(pointerEvent("pointercancel", { pointerId: 1 }));
186+
window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 }));
187+
});
188+
189+
startBeatDrag();
190+
act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
191+
192+
startBeatDrag();
193+
act(() => usePlayerStore.getState().beginTimelineSession("project-b"));
194+
releaseBeatDrag();
195+
expectCancelledBeatDrag();
196+
});
197+
198+
it("cancels without mutation when the captured music source disappears", () => {
199+
mountBeatStrip();
200+
startBeatDrag();
201+
act(() => usePlayerStore.setState({ elements: [] }));
202+
releaseBeatDrag();
203+
expectCancelledBeatDrag();
204+
});
205+
206+
it("cancels when the captured beat disappears during the gesture", () => {
207+
mountBeatStrip();
208+
startBeatDrag();
209+
act(() => {
210+
usePlayerStore.setState({
211+
beatAnalysis: { ...BEAT_ANALYSIS, beatTimes: [3], beatStrengths: [0.8] },
212+
});
213+
});
214+
releaseBeatDrag();
215+
expectCancelledBeatDrag();
216+
});
217+
218+
it("releases the actor when the owning timeline viewport unmounts", async () => {
219+
const { viewport } = mountBeatStrip();
220+
startBeatDrag();
221+
222+
await act(async () => {
223+
viewport.remove();
224+
await Promise.resolve();
225+
});
226+
releaseBeatDrag();
227+
expectCancelledBeatDrag();
228+
});
229+
230+
it("ignores unrelated pointers and keeps the active beat rendered outside the time window", () => {
231+
const { root } = mountBeatStrip();
232+
startBeatDrag();
233+
act(() => {
234+
window.dispatchEvent(
235+
pointerEvent("pointermove", {
236+
bubbles: true,
237+
clientX: 150,
238+
clientY: 100,
239+
pointerId: 2,
240+
}),
241+
);
242+
window.dispatchEvent(
243+
pointerEvent("pointerup", {
244+
bubbles: true,
245+
clientX: 150,
246+
clientY: 100,
247+
pointerId: 2,
248+
}),
249+
);
250+
root.render(
251+
<BeatStrip
252+
beatTimes={[1, 3]}
253+
beatStrengths={[0.5, 0.8]}
254+
pps={100}
255+
renderTimeRange={{ start: 2.5, end: 3.5 }}
256+
/>,
257+
);
258+
});
259+
260+
expect(
261+
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
262+
).toHaveLength(2);
263+
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
264+
265+
act(() => {
266+
window.dispatchEvent(
267+
pointerEvent("pointerup", {
268+
bubbles: true,
269+
clientX: 150,
270+
clientY: 100,
271+
pointerId: 1,
272+
}),
273+
);
274+
});
275+
expect(commitBeatEditsSpy).toHaveBeenCalledOnce();
276+
});
277+
278+
it("does not autoscroll or mutate below the drag threshold", () => {
279+
const requestAnimationFrame = vi.fn(() => 1);
280+
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
281+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
282+
mountBeatStrip();
283+
startBeatDrag(990);
284+
285+
act(() => {
286+
window.dispatchEvent(
287+
pointerEvent("pointermove", {
288+
bubbles: true,
289+
clientX: 991,
290+
clientY: 100,
291+
pointerId: 1,
292+
}),
293+
);
294+
window.dispatchEvent(
295+
pointerEvent("pointerup", {
296+
bubbles: true,
297+
clientX: 991,
298+
clientY: 100,
299+
pointerId: 1,
300+
}),
301+
);
302+
});
303+
304+
expect(requestAnimationFrame).not.toHaveBeenCalled();
305+
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
306+
});
307+
});

0 commit comments

Comments
 (0)