fix(studio): timeline clips keep their pictures while you scroll (#4305)

* fix(studio): timeline clips keep their pictures while you scroll

With row virtualization on, the timeline dropped every clip's picture for the length of a scroll
gesture and put it back 100ms after the gesture stopped, so a zoomed-in timeline showed grey boxes
the whole time a person was moving through it. Clip content now stays mounted while scrolling;
pictures already loaded come straight from the thumbnail cache.

* perf(studio): clips that scroll into view get their pictures when the scroll stops

Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport
gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main
has none. A clip's content now mounts only while the timeline is at rest and then stays through
later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
This commit is contained in:
Miguel Ángel
2026-09-23 10:39:22 -04:00
committed by GitHub
parent 8cb7d06db6
commit 2c70e65784
5 changed files with 71 additions and 26 deletions
@@ -140,7 +140,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
usePlayerStore.getState().reset();
});
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
it("keeps a clip's picture on screen while the timeline scrolls", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
@@ -167,17 +167,62 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
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();
const picture = host.querySelector("[data-rich-content]");
expect(picture).not.toBeNull();
if (scroller) await dispatchScroll(scroller);
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).toBeNull();
expect(host.querySelector("[data-rich-content]")).toBe(picture);
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();
expect(host.querySelector("[data-rich-content]")).toBe(picture);
} finally {
act(() => root.unmount());
usePlayerStore.getState().reset();
}
});
it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
const clip = (id: string, start: number) => ({
id,
label: id,
tag: "div",
start,
duration: 5,
track: 0,
});
usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
const { host, root } = await mountTimeline(
React.createElement(Timeline, {
renderClipContent: (element: { id: string }) =>
React.createElement("span", { "data-rich-content": element.id }),
}),
);
try {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
if (scroller) await dispatchScroll(scroller);
act(() => {
usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
});
expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
} finally {
act(() => root.unmount());
usePlayerStore.getState().reset();
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TrackVisualStyle } from "./timelineIcons";
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
);
}
/**
* Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
* so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
*/
export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
const [shown, setShown] = useState(!hold);
if (!shown && !hold) setShown(true);
return shown ? children : null;
}
export function renderClipChildren(
element: TimelineElement,
clipStyle: TrackVisualStyle,
@@ -3,7 +3,6 @@
import { describe, expect, it, vi } from "vitest";
import {
buildTimelineMeta,
resolveRenderClipContent,
resolveResizingElementIds,
shouldIgnoreTimelinePointerDown,
type TimelineMetaBuilderInputs,
@@ -85,12 +84,6 @@ describe("buildTimelineMeta", () => {
});
describe("timeline provider branch helpers", () => {
it("resolves virtualized clip content", () => {
const render = () => null;
expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
expect(resolveRenderClipContent(false, true, render)).toBe(render);
});
it("resolves resizing element ids", () => {
expect(resolveResizingElementIds(null)).toBeUndefined();
expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
@@ -4,7 +4,6 @@ import type {
TimelineViewportProps,
} from "./TimelineProvider";
import type { ResizingClipState } from "./useTimelineClipDrag";
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
import type { DraggedClipState } from "./timelineClipDragTypes";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
@@ -34,14 +33,6 @@ export function resolveResizingElementIds(
return undefined;
}
export function resolveRenderClipContent(
rowVirtualizationActive: boolean,
isScrolling: boolean,
renderClipContent: TimelineLaneBaseProps["renderClipContent"],
): TimelineLaneBaseProps["renderClipContent"] {
return rowVirtualizationActive && isScrolling ? undefined : renderClipContent;
}
export function shouldIgnoreTimelinePointerDown(target: EventTarget | null): boolean {
return target instanceof Element && target.closest("button, input, select, a") !== null;
}
@@ -11,7 +11,6 @@ import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvi
import {
buildTimelineMeta,
resolveMultiDragPreview,
resolveRenderClipContent,
resolveResizingElementIds,
shouldIgnoreTimelinePointerDown,
} from "./timelineProviderStateBuilders";
@@ -22,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { ClipContentOnceShown } from "./timelineClipChildren";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
import {
@@ -509,10 +509,16 @@ export function useTimelineProviderState({
onRazorSplit: editContext.onRazorSplit,
onRazorSplitAll: editContext.onRazorSplitAll,
};
const timelineRenderClipContent = resolveRenderClipContent(
timelineFocus.rowVirtualizationActive,
viewport.isScrolling,
renderClipContent,
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
const timelineRenderClipContent = useMemo<typeof renderClipContent>(
() =>
renderClipContent &&
((element, style, context) => (
<ClipContentOnceShown hold={holdNewClipContent}>
{renderClipContent(element, style, context)}
</ClipContentOnceShown>
)),
[holdNewClipContent, renderClipContent],
);
const timelineMeta = buildTimelineMeta({
emptyState: {