Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/hooks/useResizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,101 @@ describe('useResizer', () => {
});
});

describe('RAF throttling uses latest position', () => {
it('uses the latest mouse position when multiple moves occur before RAF fires', () => {
const onResize = vi.fn();
const { result } = renderHook(() =>
useResizer({
direction: 'horizontal',
sizes: [300, 700],
minSizes: [100, 100],
maxSizes: [500, 900],
onResize,
})
);

// Start drag
act(() => {
const mouseDown = result.current.handleMouseDown(0);
mouseDown({
preventDefault: vi.fn(),
clientX: 300,
clientY: 0,
nativeEvent: new MouseEvent('mousedown'),
} as unknown as React.MouseEvent);
});

// Simulate multiple rapid mouse moves BEFORE RAF fires
// This simulates what happens when mouse events fire faster than 60fps
act(() => {
// First move - schedules RAF
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 320, clientY: 0 })
);
// Second move - should be captured but RAF already pending
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 350, clientY: 0 })
);
// Third move - the latest position
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 400, clientY: 0 })
);

// Now RAF fires - should use position 400, not 320
vi.runAllTimers();
});

// Should use the LATEST position (400), not the first one (320)
expect(result.current.currentSizes[0]).toBe(400);
expect(result.current.currentSizes[1]).toBe(600);
});

it('uses the latest touch position when multiple moves occur before RAF fires', () => {
const { result } = renderHook(() =>
useResizer({
direction: 'horizontal',
sizes: [300, 700],
minSizes: [100, 100],
maxSizes: [500, 900],
})
);

// Start touch
act(() => {
const touchStart = result.current.handleTouchStart(0);
touchStart({
touches: [{ clientX: 300, clientY: 0 }],
nativeEvent: new TouchEvent('touchstart'),
} as unknown as React.TouchEvent);
});

// Simulate multiple rapid touch moves BEFORE RAF fires
act(() => {
document.dispatchEvent(
new TouchEvent('touchmove', {
touches: [{ clientX: 320, clientY: 0 } as Touch],
})
);
document.dispatchEvent(
new TouchEvent('touchmove', {
touches: [{ clientX: 350, clientY: 0 } as Touch],
})
);
document.dispatchEvent(
new TouchEvent('touchmove', {
touches: [{ clientX: 400, clientY: 0 } as Touch],
})
);

vi.runAllTimers();
});

// Should use the LATEST position (400)
expect(result.current.currentSizes[0]).toBe(400);
expect(result.current.currentSizes[1]).toBe(600);
});
});

describe('event cleanup', () => {
it('cleans up event listeners when drag ends', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
Expand Down
21 changes: 14 additions & 7 deletions src/hooks/useResizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult {

const rafRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const lastPositionRef = useRef<{ x: number; y: number } | null>(null);

// Use refs to avoid stale closures in event handlers
const currentSizesRef = useRef(currentSizes);
Expand Down Expand Up @@ -148,13 +149,16 @@ export function useResizer(options: UseResizerOptions): UseResizerResult {
(e: MouseEvent) => {
e.preventDefault();

// Always store the latest position to avoid stale closure in RAF callback
lastPositionRef.current = { x: e.clientX, y: e.clientY };

// Use RAF to throttle updates
if (rafRef.current) return;

rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (mountedRef.current) {
handleDrag(e.clientX, e.clientY);
if (mountedRef.current && lastPositionRef.current) {
handleDrag(lastPositionRef.current.x, lastPositionRef.current.y);
}
});
},
Expand All @@ -165,15 +169,18 @@ export function useResizer(options: UseResizerOptions): UseResizerResult {
(e: TouchEvent) => {
e.preventDefault();

const touch = e.touches[0];
if (!touch) return;

// Always store the latest position to avoid stale closure in RAF callback
lastPositionRef.current = { x: touch.clientX, y: touch.clientY };

if (rafRef.current) return;

rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (mountedRef.current) {
const touch = e.touches[0];
if (touch) {
handleDrag(touch.clientX, touch.clientY);
}
if (mountedRef.current && lastPositionRef.current) {
handleDrag(lastPositionRef.current.x, lastPositionRef.current.y);
}
});
},
Expand Down