mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user