Skip to content

Commit

Permalink
[@mantine/hooks] use-throttled-*: Emit updates on trailing edges
Browse files Browse the repository at this point in the history
Fixes: #6220
  • Loading branch information
dfaust committed May 20, 2024
1 parent 3a4f65d commit c10025e
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ describe('useThrottledCallback', () => {
const { result } = renderHook(() => useThrottledCallback(callback, 100));

act(() => {
result.current();
jest.advanceTimersByTime(50);
result.current();
result.current(1);
result.current(2);
jest.advanceTimersByTime(50);
result.current(3);
jest.advanceTimersByTime(100);
});

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(3);
});

it('should allow callback after throttle period', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useCallbackRef } from '../use-callback-ref/use-callback-ref';

export function useThrottledCallback<T extends (...args: any[]) => any>(callback: T, wait: number) {
export function useThrottledCallbackWithClearTimeout<T extends (...args: any[]) => any>(
callback: T,
wait: number
) {
const handleCallback = useCallbackRef(callback);
const latestInArgsRef = useRef<Parameters<T>>();
const latestOutArgsRef = useRef<Parameters<T>>();
const active = useRef(true);
const timeout = useRef<number>(-1);
const waitRef = useRef(wait);
const timeoutRef = useRef<number>(-1);

const clearTimeout = () => window.clearTimeout(timeoutRef.current);

const callThrottledCallback = useCallback(
(...args: Parameters<T>) => {
handleCallback(...args);
latestInArgsRef.current = args;
latestOutArgsRef.current = args;
active.current = false;
},
[handleCallback]
);

const timerCallback = useCallback(() => {
if (latestInArgsRef.current && latestInArgsRef.current !== latestOutArgsRef.current) {
callThrottledCallback(...latestInArgsRef.current);

timeoutRef.current = window.setTimeout(timerCallback, waitRef.current);
} else {
active.current = true;
}
}, [callThrottledCallback]);

const throttled = useCallback(
(...args: Parameters<T>) => {
if (active.current) {
active.current = false;
handleCallback(...args);
timeout.current = window.setTimeout(() => {
active.current = true;
}, wait);
callThrottledCallback(...args);
timeoutRef.current = window.setTimeout(timerCallback, waitRef.current);
} else {
latestInArgsRef.current = args;
}
},
[wait]
[callThrottledCallback, timerCallback]
);

return throttled;
useEffect(() => {
waitRef.current = wait;
}, [wait]);

return [throttled, clearTimeout] as const;
}

export function useThrottledCallback<T extends (...args: any[]) => any>(callback: T, wait: number) {
return useThrottledCallbackWithClearTimeout(callback, wait)[0];
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,34 @@ describe('useThrottledState', () => {

expect(hook.result.current[0]).toBe(1);

jest.advanceTimersByTime(100);
act(() => {
jest.advanceTimersByTime(100);
});

expect(hook.result.current[0]).toBe(3);

act(() => {
jest.advanceTimersByTime(100);

hook.result.current[1](4);
});

expect(hook.result.current[0]).toBe(4);
});

it('should clear throttling timeout on unmount', () => {
it('should clear timeout on unmount', () => {
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
const hook = renderHook(() => useThrottledState(0, 100));

act(() => {
hook.result.current[1](1);
hook.result.current[1](2);
});

hook.unmount();
jest.advanceTimersByTime(100);

expect(hook.result.current[0]).toBe(1);
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,28 +1,12 @@
import { SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useThrottledCallbackWithClearTimeout } from '../use-throttled-callback/use-throttled-callback';

export function useThrottledState<T = any>(defaultValue: T, wait: number) {
const [value, setValue] = useState(defaultValue);
const timeoutRef = useRef<number | null>(null);
const active = useRef(true);

const clearTimeout = () => window.clearTimeout(timeoutRef.current!);

const throttledSetValue = useCallback(
(newValue: SetStateAction<T>) => {
if (active.current) {
setValue(newValue);
clearTimeout();
active.current = false;

timeoutRef.current = window.setTimeout(() => {
active.current = true;
}, wait);
}
},
[wait]
);
const [setThrottledValue, clearTimeout] = useThrottledCallbackWithClearTimeout(setValue, wait);

useEffect(() => clearTimeout, []);

return [value, throttledSetValue] as const;
return [value, setThrottledValue] as const;
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,30 @@ describe('useThrottledValue', () => {
jest.advanceTimersByTime(3000);
});

expect(result.current).toBe('updated-2');
expect(result.current).toBe('updated-3');
});

it('should clear timeout on unmount', () => {
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
const { unmount } = renderHook(() => useThrottledValue('initial', 1000));
const { result, rerender, unmount } = renderHook(
({ value, delay }) => useThrottledValue(value, delay),
{
initialProps: { value: 'initial', delay: 1000 },
}
);

act(() => {
rerender({ value: 'updated', delay: 1000 });
});

act(() => {
rerender({ value: 'updated-2', delay: 1000 });
});

unmount();
jest.advanceTimersByTime(1000);

expect(result.current).toBe('updated');
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useThrottledCallbackWithClearTimeout } from '../use-throttled-callback/use-throttled-callback';

export function useThrottledValue<T>(value: T, wait: number) {
const [throttledValue, setThrottledValue] = useState(value);
const valueRef = useRef(value);
const active = useRef(true);
const timeoutRef = useRef<number>(-1);

const [throttledSetValue, clearTimeout] = useThrottledCallbackWithClearTimeout(
setThrottledValue,
wait
);

useEffect(() => {
if (active.current && valueRef.current !== value) {
setThrottledValue(value);
if (value !== valueRef.current) {
valueRef.current = value;
window.clearTimeout(timeoutRef.current);
active.current = false;

timeoutRef.current = window.setTimeout(() => {
active.current = true;
}, wait);
throttledSetValue(value);
}
}, [value]);
}, [throttledSetValue, value]);

useEffect(() => () => window.clearTimeout(timeoutRef.current), []);
useEffect(() => clearTimeout, []);

return throttledValue;
}

0 comments on commit c10025e

Please sign in to comment.