diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 8e515568c..22487bed6 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Profiler, type ProfilerOnRenderCallback } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; // The regression under test is geometric, so the environment has to have a size: // jsdom reports 0 for every box, which would leave `pxPerSec` at 0 (the @@ -72,6 +73,7 @@ function renderTimeline( clips = [clip(0, TOTAL_SEC)], annotation = { id: "ann1", startMs: 10_000, endMs: 11_000 }, assets: Array> = [NO_CAMERA_ASSET], + onRender?: ProfilerOnRenderCallback, ) { const tl = { clips, @@ -94,26 +96,41 @@ function renderTimeline( /* the toolbar only awaits it */ }), }; - render( + const setCurrentTime = vi.fn(); + const timeline = ( } - setCurrentTime={vi.fn()} + setCurrentTime={setCurrentTime} playing={false} onTogglePlay={vi.fn()} onPrevClip={vi.fn()} onNextClip={vi.fn()} onEditClip={vi.fn()} - />, + /> + ); + render( + onRender ? ( + + {timeline} + + ) : ( + timeline + ), ); return { pill: screen.getByTitle("toolbar.newAnnotation"), clipEls: Array.from(document.querySelectorAll("[data-clip-id]")), tl, + setCurrentTime, }; } +afterEach(() => { + vi.unstubAllGlobals(); +}); + /** Drag a handle by `dxPx`. The move/up listeners live on `window`, so the drag * is driven by pointer deltas alone — the handle may re-mount under it. */ function dragHandle(handle: Element, dxPx: number) { @@ -134,6 +151,51 @@ function zoomIn(notches: number) { wheelZoomOn(document.querySelector("[class*=tlTracks]") as HTMLElement, notches); } +describe("V4Timeline scrubbing", () => { + it("publishes at most one React scrub-state update per animation frame", () => { + const frames = new Map(); + let nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frameId = nextFrameId++; + frames.set(frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (frameId: number) => { + frames.delete(frameId); + }); + const onRender = vi.fn(); + const { setCurrentTime } = renderTimeline( + [clip(0, TOTAL_SEC)], + { id: "ann1", startMs: 10_000, endMs: 11_000 }, + [NO_CAMERA_ASSET], + onRender, + ); + const ruler = document.querySelector("[class*=tlRulerRow]"); + expect(ruler).not.toBeNull(); + + fireEvent.pointerDown(ruler as HTMLElement, { button: 0, clientX: 90 }); + const commitsAfterPointerDown = onRender.mock.calls.length; + setCurrentTime.mockClear(); + + fireEvent.pointerMove(window, { clientX: 180 }); + fireEvent.pointerMove(window, { clientX: 270 }); + fireEvent.pointerMove(window, { clientX: 360 }); + + expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown); + expect(setCurrentTime).not.toHaveBeenCalled(); + expect(frames.size).toBe(1); + + const [[frameId, frame]] = frames; + frames.delete(frameId); + act(() => frame(0)); + + expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown + 1); + expect(setCurrentTime).toHaveBeenCalledTimes(1); + expect(setCurrentTime).toHaveBeenCalledWith(720); + fireEvent.pointerUp(window); + }); +}); + describe("V4Timeline lane pills", () => { it("draws a pill exactly as wide as its region, at any zoom", () => { // 1 s of 1800 s. The old `Math.max(1.5, …)` floor drew this as 1.5% — 27 diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index f6c94e2ca..63db5f365 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -574,8 +574,6 @@ export function V4Timeline({ playheadElRef.current.style.left = `${pct * 100}%`; } - // Optimistic local UI state update - setScrubbingTimeSec(targetTime); pendingSeekTimeRef.current = targetTime; if (isImmediate) { @@ -583,15 +581,17 @@ export function V4Timeline({ cancelAnimationFrame(rafSeekRef.current); rafSeekRef.current = 0; } + setScrubbingTimeSec(targetTime); setCurrentTime(targetTime); return; } - // Throttled store update / D3D seek via rAF to avoid IPC flooding + // Throttled React state + store update / D3D seek via rAF to avoid re-render and IPC floods. if (rafSeekRef.current === 0) { rafSeekRef.current = requestAnimationFrame(() => { rafSeekRef.current = 0; if (pendingSeekTimeRef.current !== null) { + setScrubbingTimeSec(pendingSeekTimeRef.current); setCurrentTime(pendingSeekTimeRef.current); } });