From 83f239db68467ba5a48504c162e6606f7b51c71e Mon Sep 17 00:00:00 2001 From: knilink Date: Mon, 6 Mar 2023 16:35:05 +1100 Subject: [PATCH] fix: improve debounce implementation --- package-lock.json | 66 +++++++++++++++ package.json | 1 + src/components/HoverDropdownMenu/index.jsx | 12 +-- src/components/Search/index.jsx | 18 ++-- src/hooks/useDebounce.js | 70 ++++++++++++++++ src/hooks/useDebounce.spec.js | 98 ++++++++++++++++++++++ 6 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useDebounce.js create mode 100644 src/hooks/useDebounce.spec.js diff --git a/package-lock.json b/package-lock.json index c19fd8e34..4185bd332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,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", @@ -5876,6 +5877,36 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -20572,6 +20603,22 @@ "object-assign": "^4.1.1" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", @@ -29162,6 +29209,16 @@ "@types/react-dom": "<18.0.0" } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -40004,6 +40061,15 @@ } } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", diff --git a/package.json b/package.json index f0484d311..3b1d741ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/HoverDropdownMenu/index.jsx b/src/components/HoverDropdownMenu/index.jsx index 7bff4f1a3..6b475792e 100644 --- a/src/components/HoverDropdownMenu/index.jsx +++ b/src/components/HoverDropdownMenu/index.jsx @@ -1,8 +1,8 @@ -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 }) => { @@ -10,9 +10,11 @@ const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children const [isOpen, setIsOpen] = React.useState(false); const [mouseInPopover, setMouseInPopover] = React.useState(false); - const closeMenu = _.debounce(() => { - setIsOpen(false); - }, 100); + const [setIsOpenDebouncily, setIsOpenImmediately] = useDebounce(setIsOpen, 100); + + const closeMenu = React.useCallback(() => { + setIsOpenDebouncily(false); + }, [setIsOpenDebouncily]); const popoverEnterHandler = React.useCallback(() => setMouseInPopover(true), [setMouseInPopover]); const popoverLeaveHandler = React.useCallback(() => { @@ -28,7 +30,7 @@ const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children }, [popperNode, popoverEnterHandler, popoverLeaveHandler]); const openMenu = () => { - setIsOpen(true); + setIsOpenImmediately(true); setMouseInPopover(false); }; diff --git a/src/components/Search/index.jsx b/src/components/Search/index.jsx index 79de9e111..00cb8ec65 100644 --- a/src/components/Search/index.jsx +++ b/src/components/Search/index.jsx @@ -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( @@ -28,6 +29,8 @@ const Search = React.forwardRef( ) => { const [inputValue, setInputValue] = React.useState(''); + const [handleSearchDebouncily, handleSearchImmediately] = useDebounce(onSearch, debounceInterval); + const onInputChange = (event) => { const eventValue = _.get(event, 'target.value'); if (onChange) { @@ -36,7 +39,7 @@ const Search = React.forwardRef( setInputValue(eventValue); } if (!searchOnEnter) { - onInputSearch(eventValue); + handleSearchDebouncily(eventValue); } }; @@ -48,26 +51,23 @@ const Search = React.forwardRef( } else { setInputValue(''); } - if (!searchOnEnter) onInputSearch(emptyValue); + if (!searchOnEnter) { + handleSearchImmediately(emptyValue); + } if (onClear) onClear(emptyValue); }; const onKeyPress = (event) => { if (searchOnEnter && event.key === 'Enter') { event.preventDefault(); - onInputSearch(_.get(event, 'target.value')); + handleSearchImmediately(_.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); + handleSearchImmediately(searchValue); }; const currentInputValue = value || inputValue; diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 000000000..c160c8b09 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,70 @@ +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, function]} - An array containing four functions: + * - handleDebouncily: A function that will handle the debounced function call. + * - handleImmediately: A function that will immediately call the debounced function without waiting for the debounce interval. + * - 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, debounceInterval: null, func }); + debounceRef.current.debounceInterval = wait; + debounceRef.current.func = func; + + const handleDebouncily = React.useCallback( + (...args) => { + if (debounceRef.current.timer) { + clearTimeout(debounceRef.current.timer); + debounceRef.current.timer = null; + } + if (!debounceRef.current.debounceInterval) debounceRef.current.func(...args); + debounceRef.current.args = args; + debounceRef.current.timer = setTimeout(() => { + debounceRef.current.timer = null; + debounceRef.current.func(...args); + }, debounceRef.current.debounceInterval); + }, + [debounceRef] + ); + + const handleImmediately = React.useCallback( + (...args) => { + if (debounceRef.current.timer) { + clearTimeout(debounceRef.current.timer); + debounceRef.current.timer = null; + } + debounceRef.current.func(...args); + }, + [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] + ); + + return [handleDebouncily, handleImmediately, flush, cancel]; +}; diff --git a/src/hooks/useDebounce.spec.js b/src/hooks/useDebounce.spec.js new file mode 100644 index 000000000..2920fa591 --- /dev/null +++ b/src/hooks/useDebounce.spec.js @@ -0,0 +1,98 @@ +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(4); + expect(result.current[0]).toBeInstanceOf(Function); + expect(result.current[1]).toBeInstanceOf(Function); + expect(result.current[2]).toBeInstanceOf(Function); + expect(result.current[3]).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 call the function immediately using handleImmediately', () => { + const { result } = renderHook(() => useDebounce(mockFunc, wait)); + const [, handleImmediately] = result.current; + + act(() => { + handleImmediately(...args); + }); + + 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(); + }); +});