Skip to content

Commit

Permalink
fix: improve debounce implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
knilink committed Mar 9, 2023
1 parent 4709984 commit bcd81d4
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 13 deletions.
66 changes: 66 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@mdx-js/react": "^1.6.22",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.13",
"axios": "^1.1.2",
Expand Down
11 changes: 7 additions & 4 deletions src/components/HoverDropdownMenu/index.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import Popover from '../Popover';
import PopoverLinkItem from './PopoverLinkItem';
import { useDebounce } from '../../hooks/useDebounce';
import './styles.css';

const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children }) => {
const [popperNode, setPopperNode] = React.useState(null);
const [isOpen, setIsOpen] = React.useState(false);
const [mouseInPopover, setMouseInPopover] = React.useState(false);

const closeMenu = _.debounce(() => {
setIsOpen(false);
}, 100);
const [setIsOpenDebouncily, , cancel] = useDebounce(setIsOpen, 100);

const closeMenu = React.useCallback(() => {
setIsOpenDebouncily(false);
}, [setIsOpenDebouncily]);

const popoverEnterHandler = React.useCallback(() => setMouseInPopover(true), [setMouseInPopover]);
const popoverLeaveHandler = React.useCallback(() => {
Expand All @@ -28,6 +30,7 @@ const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children
}, [popperNode, popoverEnterHandler, popoverLeaveHandler]);

const openMenu = () => {
cancel();
setIsOpen(true);
setMouseInPopover(false);
};
Expand Down
26 changes: 17 additions & 9 deletions src/components/Search/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import classnames from 'classnames';
import PropTypes from 'prop-types';
import Button from '../Button';
import Spinner from '../Spinner';
import { useDebounce } from '../../hooks/useDebounce';
import './styles.css';

const Search = React.forwardRef(
Expand All @@ -28,6 +29,8 @@ const Search = React.forwardRef(
) => {
const [inputValue, setInputValue] = React.useState('');

const [handleSearchDebouncily, , cancel] = useDebounce(onSearch, debounceInterval);

const onInputChange = (event) => {
const eventValue = _.get(event, 'target.value');
if (onChange) {
Expand All @@ -36,7 +39,12 @@ const Search = React.forwardRef(
setInputValue(eventValue);
}
if (!searchOnEnter) {
onInputSearch(eventValue);
if (debounceInterval) {
handleSearchDebouncily(eventValue);
} else {
cancel();
onSearch(eventValue);
}
}
};

Expand All @@ -48,26 +56,26 @@ const Search = React.forwardRef(
} else {
setInputValue('');
}
if (!searchOnEnter) onInputSearch(emptyValue);
if (!searchOnEnter) {
cancel();
onSearch(emptyValue);
}
if (onClear) onClear(emptyValue);
};

const onKeyPress = (event) => {
if (searchOnEnter && event.key === 'Enter') {
event.preventDefault();
onInputSearch(_.get(event, 'target.value'));
cancel();
onSearch(_.get(event, 'target.value'));
}
};

const onInputSearch = (searchValue) => {
const search = debounceInterval ? _.debounce(onSearch, debounceInterval) : onSearch;
search(searchValue);
};

const onSearchButtonClick = (event) => {
event.preventDefault();
const searchValue = value || inputValue;
onInputSearch(searchValue);
cancel();
onSearch(searchValue);
};

const currentInputValue = value || inputValue;
Expand Down
58 changes: 58 additions & 0 deletions src/hooks/useDebounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';

/**
* A custom React hook that debounces a given function and returns several methods for controlling the debounced behavior.
* @param {function} func - The function to be debounced.
* @param {number} wait - The wait time (in milliseconds) before the function is debounced.
* @returns {[function, function, function]} - An array containing four functions:
* - handleDebouncily: A function that will handle the debounced function call.
* - flush: A function that will immediately call the debounced function and clear the debounce timer.
* - cancel: A function that will cancel the debounce timer without calling the debounced function.
*/
export const useDebounce = (func, wait) => {
const debounceRef = React.useRef({ timer: null, wait: null, func });
debounceRef.current.wait = wait;
debounceRef.current.func = func;

const handleDebouncily = React.useCallback(
(...args) => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
debounceRef.current.args = args;
debounceRef.current.timer = setTimeout(() => {
debounceRef.current.timer = null;
debounceRef.current.func(...args);
}, debounceRef.current.wait);
},
[debounceRef]
);

const flush = React.useCallback(() => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
debounceRef.current.func(...debounceRef.current.args);
}, [debounceRef]);

const cancel = React.useCallback(() => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
}, [debounceRef]);

React.useEffect(
() => () => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
},
[debounceRef]
);

return [handleDebouncily, flush, cancel];
};
106 changes: 106 additions & 0 deletions src/hooks/useDebounce.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useDebounce } from './useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
const mockFunc = jest.fn();
const wait = 500;
const args = [1, 2, 3];

afterEach(() => {
jest.clearAllMocks();
});

it('should return an array containing four functions', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));

expect(result.current).toHaveLength(3);
expect(result.current[0]).toBeInstanceOf(Function);
expect(result.current[1]).toBeInstanceOf(Function);
expect(result.current[2]).toBeInstanceOf(Function);
});

it('should debounce the function call using the provided wait time', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily] = result.current;

act(() => {
handleDebouncily(...args);
});

// Before debounce interval
expect(mockFunc).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(wait - 1);
});

// During debounce interval
expect(mockFunc).not.toHaveBeenCalled();

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

// After debounce interval
expect(mockFunc).toHaveBeenCalledTimes(1);
expect(mockFunc).toHaveBeenCalledWith(...args);
});

it('should immediately call the function and clear the debounce timer using flush', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily, flush] = result.current;

act(() => {
handleDebouncily(...args);
flush();
});

expect(mockFunc).toHaveBeenCalledTimes(1);
expect(mockFunc).toHaveBeenCalledWith(...args);

act(() => {
jest.runOnlyPendingTimers();
});

expect(mockFunc).toHaveBeenCalledTimes(1);
});

it('should cancel the debounce timer using cancel', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily, , cancel] = result.current;

act(() => {
handleDebouncily(...args);
cancel();
});

act(() => {
jest.runOnlyPendingTimers();
});

expect(mockFunc).not.toHaveBeenCalled();
});

it('should clear the debounce timer on unmount', () => {
const { result, unmount } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily] = result.current;

act(() => {
handleDebouncily(...args);
});

// Before unmount
expect(mockFunc).not.toHaveBeenCalled();

unmount();

// After unmount
act(() => {
jest.runOnlyPendingTimers();
});

expect(mockFunc).not.toHaveBeenCalled();
});
});

0 comments on commit bcd81d4

Please sign in to comment.