diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx
index e4f233dbcf4eb..7565fb5cccb52 100644
--- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx
+++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx
@@ -243,6 +243,7 @@ describe.skipIf(isJSDOM)(' - Zoom', () => {
onZoomChange={onZoomChange}
zoomInteractionConfig={{
zoom: ['tapAndDrag'],
+ pan: [],
}}
/>,
options,
diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx
index 47e57a6187570..7a3b654a1ac68 100644
--- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx
+++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx
@@ -1,5 +1,5 @@
/* eslint-disable no-promise-executor-return */
-/* eslint-disable no-await-in-loop */
+
import * as React from 'react';
import { createRenderer, fireEvent, act } from '@mui/internal-test-utils';
import { isJSDOM } from 'test/utils/skipIf';
@@ -75,20 +75,16 @@ describe.skipIf(isJSDOM)('ZoomInteractionConfig Keys and Modes', () => {
]);
// Wheel without modifier keys - should not zoom
- for (let i = 0; i < 10; i += 1) {
- fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 });
- await act(async () => new Promise((r) => requestAnimationFrame(r)));
- }
+ fireEvent.wheel(svg, { deltaY: -10, clientX: 50, clientY: 50 });
+ await act(async () => new Promise((r) => requestAnimationFrame(r)));
expect(onZoomChange.callCount).to.equal(0);
expect(getAxisTickValues('x')).to.deep.equal(['A', 'B', 'C', 'D']);
await user.keyboard('{Control>}');
// Wheel with Control key - should zoom
- for (let i = 0; i < 30; i += 1) {
- fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 });
- await act(async () => new Promise((r) => requestAnimationFrame(r)));
- }
+ fireEvent.wheel(svg, { deltaY: -30, clientX: 50, clientY: 50 });
+ await act(async () => new Promise((r) => requestAnimationFrame(r)));
await user.keyboard('{/Control}');
expect(onZoomChange.callCount).to.be.greaterThan(0);
@@ -315,10 +311,8 @@ describe.skipIf(isJSDOM)('ZoomInteractionConfig Keys and Modes', () => {
const svg = document.querySelector('svg:not([aria-hidden="true"])')!;
// Wheel - should not zoom since only pinch is enabled
- for (let i = 0; i < 30; i += 1) {
- fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 });
- await act(async () => new Promise((r) => requestAnimationFrame(r)));
- }
+ fireEvent.wheel(svg, { deltaY: -30, clientX: 50, clientY: 50 });
+ await act(async () => new Promise((r) => requestAnimationFrame(r)));
expect(onZoomChange.callCount).to.equal(0);
expect(getAxisTickValues('x')).to.deep.equal(['A', 'B', 'C', 'D']);
diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts
index b7b8e0bbe6316..fb3f269ca3bc7 100644
--- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts
+++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts
@@ -23,7 +23,6 @@ export const usePanOnDrag = (
) => {
const drawingArea = useSelector(store, selectorChartDrawingArea);
const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup);
- const startRef = React.useRef(null);
const config = useSelector(store, selectorPanInteractionConfig, 'drag' as const);
const isPanOnDragEnabled: boolean =
@@ -47,6 +46,8 @@ export const usePanOnDrag = (
// Add event for chart panning
React.useEffect(() => {
const element = svgRef.current;
+ let isInteracting = false;
+ const accumulatedChange = { x: 0, y: 0 };
if (element === null || !isPanOnDragEnabled) {
return () => {};
@@ -54,34 +55,38 @@ export const usePanOnDrag = (
const handlePanStart = (event: PanEvent) => {
if (!(event.detail.target as SVGElement)?.closest('[data-charts-zoom-slider]')) {
- startRef.current = store.state.zoom.zoomData;
+ isInteracting = true;
}
};
const handlePanEnd = () => {
- startRef.current = null;
+ isInteracting = false;
};
- const throttledCallback = rafThrottle((event: PanEvent, zoomData: readonly ZoomData[]) => {
- const newZoomData = translateZoom(
- zoomData,
- { x: event.detail.activeDeltaX, y: -event.detail.activeDeltaY },
- {
- width: drawingArea.width,
- height: drawingArea.height,
- },
- optionsLookup,
+ const throttledCallback = rafThrottle(() => {
+ const x = accumulatedChange.x;
+ const y = accumulatedChange.y;
+ accumulatedChange.x = 0;
+ accumulatedChange.y = 0;
+ setZoomDataCallback((prev) =>
+ translateZoom(
+ prev,
+ { x, y: -y },
+ {
+ width: drawingArea.width,
+ height: drawingArea.height,
+ },
+ optionsLookup,
+ ),
);
-
- setZoomDataCallback(newZoomData);
});
const handlePan = (event: PanEvent) => {
- const zoomData = startRef.current;
-
- if (!zoomData) {
+ if (!isInteracting) {
return;
}
- throttledCallback(event, zoomData);
+ accumulatedChange.x += event.detail.deltaX;
+ accumulatedChange.y += event.detail.deltaY;
+ throttledCallback();
};
const panHandler = instance.addInteractionListener('zoomPan', handlePan);
@@ -103,6 +108,5 @@ export const usePanOnDrag = (
drawingArea.height,
setZoomDataCallback,
store,
- startRef,
]);
};
diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts
index 7d7a20fc668aa..5b1c5d6a4ca4d 100644
--- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts
+++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts
@@ -23,7 +23,8 @@ export const usePanOnPressAndDrag = (
) => {
const drawingArea = useSelector(store, selectorChartDrawingArea);
const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup);
- const startRef = React.useRef(null);
+ const isInteracting = React.useRef(false);
+ const accumulatedChange = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const config = useSelector(store, selectorPanInteractionConfig, 'pressAndDrag' as const);
const isPanOnPressAndDragEnabled: boolean =
@@ -54,36 +55,40 @@ export const usePanOnPressAndDrag = (
const handlePressAndDragStart = (event: PressAndDragEvent) => {
if (!(event.detail.target as SVGElement)?.closest('[data-charts-zoom-slider]')) {
- startRef.current = store.state.zoom.zoomData;
+ isInteracting.current = true;
+ accumulatedChange.current = { x: 0, y: 0 };
}
};
const handlePressAndDragEnd = () => {
- startRef.current = null;
+ isInteracting.current = false;
};
- const throttledCallback = rafThrottle(
- (event: PressAndDragEvent, zoomData: readonly ZoomData[]) => {
- const newZoomData = translateZoom(
- zoomData,
- { x: event.detail.activeDeltaX, y: -event.detail.activeDeltaY },
+ const throttledCallback = rafThrottle(() => {
+ const x = accumulatedChange.current.x;
+ const y = accumulatedChange.current.y;
+ accumulatedChange.current.x = 0;
+ accumulatedChange.current.y = 0;
+ setZoomDataCallback((prev) =>
+ translateZoom(
+ prev,
+ { x, y: -y },
{
width: drawingArea.width,
height: drawingArea.height,
},
optionsLookup,
- );
-
- setZoomDataCallback(newZoomData);
- },
- );
+ ),
+ );
+ });
const handlePressAndDrag = (event: PressAndDragEvent) => {
- const zoomData = startRef.current;
- if (!zoomData) {
+ if (!isInteracting.current) {
return;
}
- throttledCallback(event, zoomData);
+ accumulatedChange.current.x += event.detail.deltaX;
+ accumulatedChange.current.y += event.detail.deltaY;
+ throttledCallback();
};
const pressAndDragHandler = instance.addInteractionListener(
@@ -114,6 +119,6 @@ export const usePanOnPressAndDrag = (
drawingArea.height,
setZoomDataCallback,
store,
- startRef,
+ isInteracting,
]);
};
diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts
index d6174673eb8a6..07d01c45ce323 100644
--- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts
+++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts
@@ -42,10 +42,24 @@ export const useChartProZoom: ChartPlugin = (pluginDat
});
}, [store, zoomInteractionConfig]);
+ // This is debounced. We want to run it only once after the interaction ends.
+ const removeIsInteracting = React.useMemo(
+ () =>
+ debounce(
+ () =>
+ store.set('zoom', {
+ ...store.state.zoom,
+ isInteracting: false,
+ }),
+ 166,
+ ),
+ [store],
+ );
+
// Manage controlled state
React.useEffect(() => {
if (paramsZoomData === undefined) {
- return undefined;
+ return;
}
if (process.env.NODE_ENV !== 'production' && !store.state.zoom.isControlled) {
@@ -64,47 +78,28 @@ export const useChartProZoom: ChartPlugin = (pluginDat
isInteracting: true,
zoomData: paramsZoomData,
});
-
- const timeout = setTimeout(() => {
- store.set('zoom', {
- ...store.state.zoom,
- isInteracting: false,
- });
- }, 166);
-
- return () => {
- clearTimeout(timeout);
- };
- }, [store, paramsZoomData]);
-
- // This is debounced. We want to run it only once after the interaction ends.
- const removeIsInteracting = React.useMemo(
- () =>
- debounce(
- () =>
- store.set('zoom', {
- ...store.state.zoom,
- isInteracting: false,
- }),
- 166,
- ),
- [store],
- );
+ removeIsInteracting();
+ }, [store, paramsZoomData, removeIsInteracting]);
const setZoomDataCallback = React.useCallback(
(zoomData: ZoomData[] | ((prev: ZoomData[]) => ZoomData[])) => {
const newZoomData =
typeof zoomData === 'function' ? zoomData([...store.state.zoom.zoomData]) : zoomData;
- onZoomChange?.(newZoomData);
+
+ onZoomChange(newZoomData);
if (store.state.zoom.isControlled) {
- return;
+ store.set('zoom', {
+ ...store.state.zoom,
+ isInteracting: true,
+ });
+ } else {
+ store.set('zoom', {
+ ...store.state.zoom,
+ isInteracting: true,
+ zoomData: newZoomData,
+ });
+ removeIsInteracting();
}
- removeIsInteracting();
- store.set('zoom', {
- ...store.state.zoom,
- isInteracting: true,
- zoomData: newZoomData,
- });
},
[onZoomChange, store, removeIsInteracting],
);
diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts
index 4bbdc0a2c0a57..6df012135960e 100644
--- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts
+++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts
@@ -81,13 +81,11 @@ export const useChartInteractionListener: ChartPlugin {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('should throttle multiple calls to execute at most once per frame', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ throttled('first', 1);
+ throttled('second', 2);
+ throttled('third', 3);
+
+ // Function should not be called yet
+ expect(fn).not.toHaveBeenCalled();
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ expect(fn).toHaveBeenCalledWith('third', 3);
+ });
+
+ it('should cancel pending calls when clear is called', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ throttled(1);
+ throttled(2);
+ throttled(3);
+
+ // Clear before RAF executes
+ throttled.clear();
+
+ vi.advanceTimersToNextFrame();
+
+ // Function should not be called
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it('should allow new calls after clear', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ throttled(1);
+ throttled.clear();
+ throttled(2);
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ expect(fn).toHaveBeenCalledWith(2);
+ });
+
+ it('should execute on every frame when called continuously', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ // First frame
+ throttled(1);
+ vi.advanceTimersToNextFrame();
+
+ // Second frame
+ throttled(2);
+ vi.advanceTimersToNextFrame();
+
+ // Third frame
+ throttled(3);
+ vi.advanceTimersToNextFrame();
+
+ // Should execute once per frame
+ expect(fn).toHaveBeenCalledTimes(3);
+ expect(fn).toHaveBeenNthCalledWith(1, 1);
+ expect(fn).toHaveBeenNthCalledWith(2, 2);
+ expect(fn).toHaveBeenNthCalledWith(3, 3);
+ });
+
+ it('should work with functions that return values', async () => {
+ const fn = vi.fn().mockReturnValue(42);
+ const throttled = rafThrottle(fn);
+
+ throttled(1);
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn).toHaveBeenCalledWith(1);
+ expect(fn).toHaveReturnedWith(42);
+ });
+
+ it('should schedule a new RAF after the previous one completes', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+ const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
+
+ throttled(1);
+ expect(rafSpy).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersToNextFrame();
+ expect(fn).toHaveBeenCalledWith(1);
+
+ throttled(2);
+ expect(rafSpy).toHaveBeenCalledTimes(2);
+
+ vi.advanceTimersToNextFrame();
+ expect(fn).toHaveBeenCalledWith(2);
+ });
+
+ it('should handle multiple independent throttled functions', async () => {
+ const fn1 = vi.fn();
+ const fn2 = vi.fn();
+ const throttled1 = rafThrottle(fn1);
+ const throttled2 = rafThrottle(fn2);
+
+ throttled1('a');
+ throttled2('b');
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn1).toHaveBeenCalledWith('a');
+ expect(fn2).toHaveBeenCalledWith('b');
+ });
+
+ it('should properly clear and not affect other throttled functions', async () => {
+ const fn1 = vi.fn();
+ const fn2 = vi.fn();
+ const throttled1 = rafThrottle(fn1);
+ const throttled2 = rafThrottle(fn2);
+
+ throttled1('a');
+ throttled2('b');
+ throttled1.clear();
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn1).not.toHaveBeenCalled();
+ expect(fn2).toHaveBeenCalledWith('b');
+ });
+
+ it('should handle edge case of clear being called multiple times', async () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ throttled(1);
+ throttled.clear();
+ throttled.clear(); // Should not throw
+
+ vi.advanceTimersToNextFrame();
+
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it('should handle edge case of clear being called when nothing is pending', () => {
+ const fn = vi.fn();
+ const throttled = rafThrottle(fn);
+
+ // Should not throw when clearing with nothing pending
+ expect(() => throttled.clear()).not.toThrow();
+ });
+});