fix(studio): throttle FlatSlider commits instead of debouncing them

A pure debounce resets its timer on every pointermove, so a real drag
(events faster than 40ms apart) never commits until the pointer pauses
or lifts — killing live preview updates mid-drag. Throttle with a
leading-edge commit + trailing flush instead.
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:57 -07:00
parent 567b0aa017
commit a1fccfa748
2 changed files with 51 additions and 21 deletions
@@ -306,8 +306,8 @@ describe("FlatSlider", () => {
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
); );
}); });
// Instant, un-debounced knob feedback via aria-valuenow (draft state) — // Instant, un-throttled knob feedback via aria-valuenow (draft state) —
// this must update on every pointermove regardless of the commit debounce. // this must update on every pointermove regardless of the commit throttle.
expect(track.getAttribute("aria-valuenow")).toBe("10"); expect(track.getAttribute("aria-valuenow")).toBe("10");
act(() => { act(() => {
track.dispatchEvent( track.dispatchEvent(
@@ -327,7 +327,7 @@ describe("FlatSlider", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
it("coalesces rapid drag commits to only the final value on release, not every step", () => { it("throttles rapid drag commits to leading edge + final value on release, not every step", () => {
const onCommit = vi.fn(); const onCommit = vi.fn();
const { host, root } = renderInto( const { host, root } = renderInto(
<FlatSlider <FlatSlider
@@ -346,6 +346,8 @@ describe("FlatSlider", () => {
value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
}); });
act(() => { act(() => {
// pointerdown fires the leading-edge commit immediately — a live
// preview needs to move the instant the drag starts, not wait 40ms.
track.dispatchEvent( track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
); );
@@ -356,9 +358,12 @@ describe("FlatSlider", () => {
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }), new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
); );
}); });
// None of the rapid intermediate positions (10, 80) have committed yet — // The leading-edge commit (10) fired; the rapid intermediate position (80)
// only the debounce timer or the pointerup flush should ever call onCommit. // from the first pointermove never committed — it's within the 40ms
expect(onCommit).not.toHaveBeenCalled(); // throttle window, so only the trailing flush or the pointerup release
// gets to send the next value.
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledWith(10);
act(() => { act(() => {
// Real pointerup events always carry the pointer's true release position // Real pointerup events always carry the pointer's true release position
// (matches the last pointermove) — the handler recomputes from this // (matches the last pointermove) — the handler recomputes from this
@@ -368,8 +373,8 @@ describe("FlatSlider", () => {
); );
}); });
// Release flushes immediately with the LAST position only. // Release flushes immediately with the LAST position only.
expect(onCommit).toHaveBeenCalledTimes(1); expect(onCommit).toHaveBeenCalledTimes(2);
expect(onCommit).toHaveBeenCalledWith(50); expect(onCommit).toHaveBeenNthCalledWith(2, 50);
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -241,18 +241,27 @@ export function FlatSlider({
onReset?: () => void; onReset?: () => void;
onCommit: (nextValue: number) => void; onCommit: (nextValue: number) => void;
}) { }) {
// Draft/debounce mirrors the legacy SliderControl (propertyPanelPrimitives.tsx): // `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
// a real drag fires pointermove far faster than any commit should hit the // throttled (not debounced) to at most once per 40ms: a real drag fires
// network — `draft` gives the knob instant, drag-local feedback while the // pointermove faster than that, and a pure debounce (reset the timer on
// actual onCommit call is coalesced to the last value every 40ms, with an // every move) never commits until the pointer pauses or lifts — which kills
// immediate flush on release so the drag never waits out the debounce. // live preview updates during a continuous drag. Throttling still fires on
// the leading edge and on a trailing timer, so the preview keeps updating
// while dragging, with an immediate flush on release for the final value.
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const valueRef = useRef(value); const lastCommitAtRef = useRef(0);
valueRef.current = value; const pendingRef = useRef<number | null>(null);
// Tracks the last value actually sent to onCommit — separate from `value`
// (the committed prop) because in a single pointerdown+pointerup click the
// leading-edge commit fires before the parent has re-rendered with the new
// prop, so the release flush must dedupe against what we just sent, not
// against the stale prop, or the same value commits twice.
const lastCommittedRef = useRef(value);
useEffect(() => { useEffect(() => {
setDraft(value); setDraft(value);
lastCommittedRef.current = value;
}, [value]); }, [value]);
useEffect( useEffect(
() => () => { () => () => {
@@ -270,14 +279,30 @@ export function FlatSlider({
return Math.max(min, Math.min(max, stepped)); return Math.max(min, Math.min(max, stepped));
}; };
const commitDraft = (nextDraft: number) => { const commitDraft = (nextDraft: number) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current); if (commitTimerRef.current) {
if (nextDraft !== valueRef.current) onCommit(nextDraft); clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
pendingRef.current = null;
lastCommitAtRef.current = Date.now();
if (nextDraft !== lastCommittedRef.current) {
lastCommittedRef.current = nextDraft;
onCommit(nextDraft);
}
}; };
const scheduleCommit = (nextDraft: number) => { const scheduleCommit = (nextDraft: number) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current); const elapsed = Date.now() - lastCommitAtRef.current;
commitTimerRef.current = setTimeout(() => { if (elapsed >= 40) {
if (nextDraft !== valueRef.current) onCommit(nextDraft); commitDraft(nextDraft);
}, 40); return;
}
pendingRef.current = nextDraft;
if (!commitTimerRef.current) {
commitTimerRef.current = setTimeout(() => {
commitTimerRef.current = null;
if (pendingRef.current !== null) commitDraft(pendingRef.current);
}, 40 - elapsed);
}
}; };
return ( return (