fix(studio): debounce flat slider drag commits to prevent rapid-fire writes

This commit is contained in:
Vance Ingalls
2026-07-14 15:51:57 -07:00
parent c699778689
commit 567b0aa017
5 changed files with 114 additions and 15 deletions
@@ -52,6 +52,7 @@ function dragSliderTrack(row: Element, clientX: number, trackWidth: number) {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX }));
});
}
@@ -155,7 +155,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
it("commits a new volume value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -177,8 +177,9 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
// starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
act(() => root.unmount());
});
@@ -207,6 +208,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
@@ -237,6 +239,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
mediaStartTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000
// ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00"
@@ -234,11 +234,11 @@ describe("FlatSlider", () => {
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={50}
value={10}
min={0}
max={100}
tier="explicitCustom"
displayValue="50%"
displayValue="10%"
onCommit={onCommit}
/>,
);
@@ -249,6 +249,7 @@ describe("FlatSlider", () => {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
expect(onCommit).toHaveBeenCalledWith(50);
act(() => root.unmount());
@@ -276,12 +277,13 @@ describe("FlatSlider", () => {
track.dispatchEvent(
new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 18 }),
);
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 20 }));
});
expect(onCommit).toHaveBeenCalledWith(10);
act(() => root.unmount());
});
it("commits continuously while dragging, not just on the initial pointerdown", () => {
it("tracks the knob instantly on every pointermove during a drag (draft state)", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
@@ -304,25 +306,73 @@ describe("FlatSlider", () => {
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(10);
// Instant, un-debounced knob feedback via aria-valuenow (draft state) —
// this must update on every pointermove regardless of the commit debounce.
expect(track.getAttribute("aria-valuenow")).toBe("10");
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(80);
expect(track.getAttribute("aria-valuenow")).toBe("80");
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(50);
expect(track.getAttribute("aria-valuenow")).toBe("50");
act(() => {
track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
});
act(() => root.unmount());
});
it("coalesces rapid drag commits to only the final value on release, not every step", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={5}
min={0}
max={100}
tier="explicitCustom"
displayValue="5%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
});
act(() => {
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
// None of the rapid intermediate positions (10, 80) have committed yet —
// only the debounce timer or the pointerup flush should ever call onCommit.
expect(onCommit).not.toHaveBeenCalled();
act(() => {
// Real pointerup events always carry the pointer's true release position
// (matches the last pointermove) — the handler recomputes from this
// rather than trusting a possibly-stale `draft` closure.
track.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
// Release flushes immediately with the LAST position only.
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledWith(50);
act(() => root.unmount());
});
it("ignores pointermove once a drag has ended (pointer capture released)", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { RotateCcw } from "../../icons/SystemIcons";
import { CommitField } from "./propertyPanelPrimitives";
import {
@@ -241,13 +241,43 @@ export function FlatSlider({
onReset?: () => void;
onCommit: (nextValue: number) => void;
}) {
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
// Draft/debounce mirrors the legacy SliderControl (propertyPanelPrimitives.tsx):
// a real drag fires pointermove far faster than any commit should hit the
// network — `draft` gives the knob instant, drag-local feedback while the
// actual onCommit call is coalesced to the last value every 40ms, with an
// immediate flush on release so the drag never waits out the debounce.
const [draft, setDraft] = useState(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const valueRef = useRef(value);
valueRef.current = value;
const commitFromClientX = (clientX: number, rect: DOMRect) => {
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(
() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
},
[],
);
const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100));
const stepFromClientX = (clientX: number, rect: DOMRect) => {
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
const raw = min + ratio * (max - min);
const stepped = Math.round(raw / step) * step;
onCommit(Math.max(min, Math.min(max, stepped)));
return Math.max(min, Math.min(max, stepped));
};
const commitDraft = (nextDraft: number) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
if (nextDraft !== valueRef.current) onCommit(nextDraft);
};
const scheduleCommit = (nextDraft: number) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
if (nextDraft !== valueRef.current) onCommit(nextDraft);
}, 40);
};
return (
@@ -257,22 +287,33 @@ export function FlatSlider({
data-flat-slider-track="true"
role="slider"
aria-label={label}
aria-valuenow={value}
aria-valuenow={draft}
aria-disabled={disabled}
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(e) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
scheduleCommit(stepped);
}}
onPointerMove={(e) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
scheduleCommit(stepped);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
// Recompute from the event itself rather than reading the `draft`
// closure — if pointerdown+pointerup land in the same React batch
// (e.g. a very fast click), the onPointerUp handler can still be
// bound to the pre-drag render, making `draft` stale.
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
commitDraft(stepped);
}}
>
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
@@ -372,6 +372,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
@@ -390,6 +391,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
backdropTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
@@ -551,6 +553,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
});
act(() => {
maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
maskInsetTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px.
// border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue
@@ -587,6 +590,7 @@ describe("FlatStyleSection — Opacity", () => {
});
act(() => {
opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
opacityTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
act(() => root.unmount());