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
72 changes: 72 additions & 0 deletions packages/ra-core/src/util/useDebouncedEvent.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as React from 'react';
import { render, screen, act, fireEvent } from '@testing-library/react';
import { useDebouncedEvent } from './useDebouncedEvent';

const DebouncedButtons = ({
cb,
delay = 300,
}: {
cb: (value: string) => void;
delay?: number;
}) => {
const update = useDebouncedEvent(cb, delay);
return (
<>
<button onClick={() => update('x')}>update</button>
<button onClick={() => update.cancel()}>cancel</button>
<button onClick={() => update.flush()}>flush</button>
</>
);
};

describe('useDebouncedEvent', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('calls the callback after the delay', () => {
const cb = jest.fn();
render(<DebouncedButtons cb={cb} />);
fireEvent.click(screen.getByText('update'));
expect(cb).not.toHaveBeenCalled();
act(() => jest.advanceTimersByTime(300));
expect(cb).toHaveBeenCalledWith('x');
});
it('cancels a pending call with cancel()', () => {
const cb = jest.fn();
render(<DebouncedButtons cb={cb} />);
fireEvent.click(screen.getByText('update'));
fireEvent.click(screen.getByText('cancel'));
act(() => jest.advanceTimersByTime(300));
expect(cb).not.toHaveBeenCalled();
});
it('runs a pending call immediately with flush()', () => {
const cb = jest.fn();
render(<DebouncedButtons cb={cb} />);
fireEvent.click(screen.getByText('update'));
fireEvent.click(screen.getByText('flush'));
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith('x');
act(() => jest.advanceTimersByTime(300));
expect(cb).toHaveBeenCalledTimes(1);
});
it('flushes a pending call on unmount', () => {
const cb = jest.fn();
const { unmount } = render(<DebouncedButtons cb={cb} />);
fireEvent.click(screen.getByText('update'));
unmount();
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith('x');
act(() => jest.advanceTimersByTime(300));
expect(cb).toHaveBeenCalledTimes(1);
});
it('flushes a pending call when the delay changes', () => {
const cb = jest.fn();
const { rerender } = render(<DebouncedButtons cb={cb} />);
fireEvent.click(screen.getByText('update'));
rerender(<DebouncedButtons cb={cb} delay={500} />);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith('x');
act(() => jest.advanceTimersByTime(500));
expect(cb).toHaveBeenCalledTimes(1);
});
});
42 changes: 32 additions & 10 deletions packages/ra-core/src/util/useDebouncedEvent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import * as React from 'react';
import { useCallback, useRef } from 'react';
import { useMemo, useRef } from 'react';
import debounce from 'lodash/debounce.js';
import { useEvent } from './useEvent';

export interface DebouncedEvent<Args extends unknown[], Return> {
(...args: Args): Return | undefined;
cancel: () => void;
flush: () => Return | undefined;
}

// allow the hook to work in SSR
const useLayoutEffect =
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
Expand All @@ -17,26 +23,42 @@ const useLayoutEffect =
export const useDebouncedEvent = <Args extends unknown[], Return>(
callback: (...args: Args) => Return,
delay: number
) => {
): DebouncedEvent<Args, Return> => {
// Create a ref that stores the debounced callback
const debouncedCallbackRef = useRef<(...args: Args) => Return | undefined>(
() => {
throw new Error('Cannot call an event handler while rendering.');
}
const debouncedCallbackRef = useRef<DebouncedEvent<Args, Return>>(
Object.assign(
() => {
throw new Error(
'Cannot call an event handler while rendering.'
);
},
{ cancel: () => {}, flush: () => undefined }
)
);

// Keep a stable ref to the callback (in case it's an inline function for instance)
const stableCallback = useEvent(callback);

// Whenever callback or delay changes, we need to update the debounced callback
useLayoutEffect(() => {
debouncedCallbackRef.current = debounce(stableCallback, delay);
const debounced = debounce(stableCallback, delay);
debouncedCallbackRef.current = debounced;
return () => {
debounced.flush();
};
}, [stableCallback, delay]);

// The function returned by useCallback will invoke the debounced callback
// The function returned by useMemo will invoke the debounced callback
// Its dependencies array is empty, so it never changes across re-renders
return useCallback(
(...args: Args) => debouncedCallbackRef.current(...args),
return useMemo(
() =>
Object.assign(
(...args: Args) => debouncedCallbackRef.current(...args),
{
cancel: () => debouncedCallbackRef.current.cancel(),
flush: () => debouncedCallbackRef.current.flush(),
}
),
[]
);
};
Loading