diff --git a/ui/v2.5/.eslintrc.json b/ui/v2.5/.eslintrc.json index 4438860ea..f37f8028c 100644 --- a/ui/v2.5/.eslintrc.json +++ b/ui/v2.5/.eslintrc.json @@ -53,6 +53,10 @@ "import/namespace": "off", "import/no-unresolved": "off", "react/display-name": "off", + "react-hooks/exhaustive-deps": [ + "error", + { "additionalHooks": "^(useDebounce)$" } + ], "react/prop-types": "off", "react/style-prop-object": [ "error", diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 018e3473a..f0e660328 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,10 +1,8 @@ -import debounce from "lodash-es/debounce"; import cloneDeep from "lodash-es/cloneDeep"; import React, { HTMLAttributes, useCallback, useEffect, - useMemo, useRef, useState, } from "react"; @@ -40,6 +38,7 @@ import { faRandom, faTimes, } from "@fortawesome/free-solid-svg-icons"; +import { useDebounce } from "src/hooks/debounce"; const maxPageSize = 1000; interface IListFilterProps { @@ -79,13 +78,15 @@ export const ListFilter: React.FC = ({ [filter, onFilterUpdate] ); - // useMemo to prevent debounce from being recreated on every render - const debouncedSearchQueryUpdated = useMemo( - () => - debounce((value: string) => { - searchQueryUpdated(value); - }, 500), - [searchQueryUpdated] + const searchCallback = useDebounce( + (value: string) => { + const newFilter = cloneDeep(filter); + newFilter.searchTerm = value; + newFilter.currentPage = 1; + onFilterUpdate(newFilter); + }, + [filter, onFilterUpdate], + 500 ); const intl = useIntl(); @@ -145,7 +146,7 @@ export const ListFilter: React.FC = ({ } function onChangeQuery(event: React.FormEvent) { - debouncedSearchQueryUpdated(event.currentTarget.value); + searchCallback(event.currentTarget.value); setQueryClearShowing(!!event.currentTarget.value); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx index b402bfcbe..31e093afc 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from "react"; -import debounce from "lodash-es/debounce"; import { Button, Form } from "react-bootstrap"; import { useIntl } from "react-intl"; @@ -7,6 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useScrapePerformerList } from "src/core/StashService"; +import { useDebouncedSetState } from "src/hooks/debounce"; const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; @@ -33,9 +33,7 @@ const PerformerScrapeModal: React.FC = ({ const performers = data?.scrapeSinglePerformer ?? []; - const onInputChange = debounce((input: string) => { - setQuery(input); - }, 500); + const onInputChange = useDebouncedSetState(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx index 9fc237905..5b508ea1f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from "react"; -import debounce from "lodash-es/debounce"; import { Button, Form } from "react-bootstrap"; import { useIntl } from "react-intl"; @@ -7,6 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { stashboxDisplayName } from "src/utils/stashbox"; +import { useDebouncedSetState } from "src/hooks/debounce"; const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; @@ -44,9 +44,7 @@ const PerformerStashBoxModal: React.FC = ({ const performers = data?.scrapeSinglePerformer ?? []; - const onInputChange = debounce((input: string) => { - setQuery(input); - }, 500); + const onInputChange = useDebouncedSetState(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index eb5a4c96e..cbb353a6b 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -3,14 +3,7 @@ import { faCheckCircle, faTimesCircle, } from "@fortawesome/free-solid-svg-icons"; -import debounce from "lodash-es/debounce"; -import React, { - useState, - useEffect, - useMemo, - useCallback, - useRef, -} from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Spinner } from "react-bootstrap"; import { IUIConfig } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; @@ -23,6 +16,7 @@ import { useConfigureScraping, useConfigureUI, } from "src/core/StashService"; +import { useDebounce } from "src/hooks/debounce"; import { useToast } from "src/hooks/Toast"; import { withoutTypename } from "src/utils/data"; import { Icon } from "../Shared/Icon"; @@ -76,40 +70,34 @@ export const SettingsContext: React.FC = ({ children }) => { const initialRef = useRef(false); const [general, setGeneral] = useState({}); - const [pendingGeneral, setPendingGeneral] = useState< - GQL.ConfigGeneralInput | undefined - >(); + const [pendingGeneral, setPendingGeneral] = + useState(); const [updateGeneralConfig] = useConfigureGeneral(); const [iface, setIface] = useState({}); - const [pendingInterface, setPendingInterface] = useState< - GQL.ConfigInterfaceInput | undefined - >(); + const [pendingInterface, setPendingInterface] = + useState(); const [updateInterfaceConfig] = useConfigureInterface(); const [defaults, setDefaults] = useState({}); - const [pendingDefaults, setPendingDefaults] = useState< - GQL.ConfigDefaultSettingsInput | undefined - >(); + const [pendingDefaults, setPendingDefaults] = + useState(); const [updateDefaultsConfig] = useConfigureDefaults(); const [scraping, setScraping] = useState({}); - const [pendingScraping, setPendingScraping] = useState< - GQL.ConfigScrapingInput | undefined - >(); + const [pendingScraping, setPendingScraping] = + useState(); const [updateScrapingConfig] = useConfigureScraping(); const [dlna, setDLNA] = useState({}); - const [pendingDLNA, setPendingDLNA] = useState< - GQL.ConfigDlnaInput | undefined - >(); + const [pendingDLNA, setPendingDLNA] = useState(); const [updateDLNAConfig] = useConfigureDLNA(); const [ui, setUI] = useState({}); - const [pendingUI, setPendingUI] = useState<{} | undefined>(); + const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); - const [updateSuccess, setUpdateSuccess] = useState(); + const [updateSuccess, setUpdateSuccess] = useState(); const [apiKey, setApiKey] = useState(""); @@ -146,13 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => { setUI(data.configuration.ui); }, [data, error]); - const resetSuccess = useMemo( - () => - debounce(() => { - setUpdateSuccess(undefined); - }, 4000), - [] - ); + const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), [], 4000); const onSuccess = useCallback(() => { setUpdateSuccess(true); @@ -160,24 +142,24 @@ export const SettingsContext: React.FC = ({ children }) => { }, [resetSuccess]); // saves the configuration if no further changes are made after a half second - const saveGeneralConfig = useMemo( - () => - debounce(async (input: GQL.ConfigGeneralInput) => { - try { - setUpdateSuccess(undefined); - await updateGeneralConfig({ - variables: { - input, - }, - }); + const saveGeneralConfig = useDebounce( + async (input: GQL.ConfigGeneralInput) => { + try { + setUpdateSuccess(undefined); + await updateGeneralConfig({ + variables: { + input, + }, + }); - setPendingGeneral(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateGeneralConfig, onSuccess] + setPendingGeneral(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateGeneralConfig, onSuccess], + 500 ); useEffect(() => { @@ -210,24 +192,24 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveInterfaceConfig = useMemo( - () => - debounce(async (input: GQL.ConfigInterfaceInput) => { - try { - setUpdateSuccess(undefined); - await updateInterfaceConfig({ - variables: { - input, - }, - }); + const saveInterfaceConfig = useDebounce( + async (input: GQL.ConfigInterfaceInput) => { + try { + setUpdateSuccess(undefined); + await updateInterfaceConfig({ + variables: { + input, + }, + }); - setPendingInterface(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateInterfaceConfig, onSuccess] + setPendingInterface(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateInterfaceConfig, onSuccess], + 500 ); useEffect(() => { @@ -260,24 +242,24 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveDefaultsConfig = useMemo( - () => - debounce(async (input: GQL.ConfigDefaultSettingsInput) => { - try { - setUpdateSuccess(undefined); - await updateDefaultsConfig({ - variables: { - input, - }, - }); + const saveDefaultsConfig = useDebounce( + async (input: GQL.ConfigDefaultSettingsInput) => { + try { + setUpdateSuccess(undefined); + await updateDefaultsConfig({ + variables: { + input, + }, + }); - setPendingDefaults(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateDefaultsConfig, onSuccess] + setPendingDefaults(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateDefaultsConfig, onSuccess], + 500 ); useEffect(() => { @@ -310,24 +292,24 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveScrapingConfig = useMemo( - () => - debounce(async (input: GQL.ConfigScrapingInput) => { - try { - setUpdateSuccess(undefined); - await updateScrapingConfig({ - variables: { - input, - }, - }); + const saveScrapingConfig = useDebounce( + async (input: GQL.ConfigScrapingInput) => { + try { + setUpdateSuccess(undefined); + await updateScrapingConfig({ + variables: { + input, + }, + }); - setPendingScraping(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateScrapingConfig, onSuccess] + setPendingScraping(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateScrapingConfig, onSuccess], + 500 ); useEffect(() => { @@ -360,24 +342,24 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveDLNAConfig = useMemo( - () => - debounce(async (input: GQL.ConfigDlnaInput) => { - try { - setUpdateSuccess(undefined); - await updateDLNAConfig({ - variables: { - input, - }, - }); + const saveDLNAConfig = useDebounce( + async (input: GQL.ConfigDlnaInput) => { + try { + setUpdateSuccess(undefined); + await updateDLNAConfig({ + variables: { + input, + }, + }); - setPendingDLNA(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateDLNAConfig, onSuccess] + setPendingDLNA(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateDLNAConfig, onSuccess], + 500 ); useEffect(() => { @@ -410,24 +392,24 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveUIConfig = useMemo( - () => - debounce(async (input: IUIConfig) => { - try { - setUpdateSuccess(undefined); - await updateUIConfig({ - variables: { - input, - }, - }); + const saveUIConfig = useDebounce( + async (input: IUIConfig) => { + try { + setUpdateSuccess(undefined); + await updateUIConfig({ + variables: { + input, + }, + }); - setPendingUI(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, 500), - [updateUIConfig, onSuccess] + setPendingUI(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, + [updateUIConfig, onSuccess], + 500 ); useEffect(() => { diff --git a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx index a31671677..01a937bfd 100644 --- a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx +++ b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, InputGroup, Form } from "react-bootstrap"; -import debounce from "lodash-es/debounce"; import { Icon } from "../Icon"; import { LoadingIndicator } from "../LoadingIndicator"; import { useDirectory } from "src/core/StashService"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { useDebouncedSetState } from "src/hooks/debounce"; interface IProps { currentDirectory: string; @@ -20,22 +20,15 @@ export const FolderSelect: React.FC = ({ defaultDirectories, appendButton, }) => { - const [debouncedDirectory, setDebouncedDirectory] = - useState(currentDirectory); - const { data, error, loading } = useDirectory(debouncedDirectory); + const [directory, setDirectory] = useState(currentDirectory); + const { data, error, loading } = useDirectory(directory); const intl = useIntl(); const selectableDirectories: string[] = currentDirectory ? data?.directory.directories ?? defaultDirectories ?? [] : defaultDirectories ?? []; - const debouncedSetDirectory = useMemo( - () => - debounce((input: string) => { - setDebouncedDirectory(input); - }, 250), - [] - ); + const debouncedSetDirectory = useDebouncedSetState(setDirectory, 250); useEffect(() => { if (currentDirectory === "" && !defaultDirectories && data?.directory.path) @@ -44,7 +37,7 @@ export const FolderSelect: React.FC = ({ function setInstant(value: string) { setCurrentDirectory(value); - setDebouncedDirectory(value); + setDirectory(value); } function setDebounced(value: string) { diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 61fbd7d72..195c52bd7 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -10,7 +10,6 @@ import Select, { OptionsOrGroups, } from "react-select"; import CreatableSelect from "react-select/creatable"; -import debounce from "lodash-es/debounce"; import * as GQL from "src/core/generated-graphql"; import { @@ -31,6 +30,7 @@ import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { TagPopover } from "../Tags/TagPopover"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; +import { useDebouncedSetState } from "src/hooks/debounce"; export type SelectObject = { id: string; @@ -354,9 +354,7 @@ export const GallerySelect: React.FC = (props) => { value: g.id, })); - const onInputChange = debounce((input: string) => { - setQuery(input); - }, 500); + const onInputChange = useDebouncedSetState(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); @@ -407,9 +405,7 @@ export const SceneSelect: React.FC = (props) => { value: s.id, })); - const onInputChange = debounce((input: string) => { - setQuery(input); - }, 500); + const onInputChange = useDebouncedSetState(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); @@ -459,9 +455,7 @@ export const ImageSelect: React.FC = (props) => { value: s.id, })); - const onInputChange = debounce((input: string) => { - setQuery(input); - }, 500); + const onInputChange = useDebouncedSetState(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); diff --git a/ui/v2.5/src/components/Shared/TruncatedText.tsx b/ui/v2.5/src/components/Shared/TruncatedText.tsx index d9ee8262a..99f4af1f5 100644 --- a/ui/v2.5/src/components/Shared/TruncatedText.tsx +++ b/ui/v2.5/src/components/Shared/TruncatedText.tsx @@ -1,8 +1,8 @@ import React, { useRef, useState } from "react"; import { Overlay, Tooltip } from "react-bootstrap"; import { Placement } from "react-bootstrap/Overlay"; -import debounce from "lodash-es/debounce"; import cx from "classnames"; +import { useDebounce } from "src/hooks/debounce"; const CLASSNAME = "TruncatedText"; const CLASSNAME_TOOLTIP = `${CLASSNAME}-tooltip`; @@ -25,9 +25,13 @@ export const TruncatedText: React.FC = ({ const [showTooltip, setShowTooltip] = useState(false); const target = useRef(null); - if (!text) return <>; + const startShowingTooltip = useDebounce( + () => setShowTooltip(true), + [], + delay + ); - const startShowingTooltip = debounce(() => setShowTooltip(true), delay); + if (!text) return <>; const handleFocus = (element: HTMLElement) => { // Check if visible size is smaller than the content size diff --git a/ui/v2.5/src/hooks/Interval.ts b/ui/v2.5/src/hooks/Interval.ts index bfad6a45d..87c4d528f 100644 --- a/ui/v2.5/src/hooks/Interval.ts +++ b/ui/v2.5/src/hooks/Interval.ts @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react"; -import noop from "lodash-es/noop"; const MIN_VALID_INTERVAL = 1000; +function noop() {} + const useInterval = ( callback: () => void, delay: number | null = 5000 diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index aa2f9fb39..06721cf58 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -10,7 +10,6 @@ import { } from "react-bootstrap"; import cx from "classnames"; import Mousetrap from "mousetrap"; -import debounce from "lodash-es/debounce"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -45,6 +44,7 @@ import { faTimes, } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; +import { useDebounce } from "../debounce"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; @@ -197,8 +197,9 @@ export const LightboxComponent: React.FC = ({ } }, [isSwitchingPage, images, index]); - const disableInstantTransition = debounce( + const disableInstantTransition = useDebounce( () => setInstantTransition(false), + [], 400 ); diff --git a/ui/v2.5/src/hooks/debounce.ts b/ui/v2.5/src/hooks/debounce.ts new file mode 100644 index 000000000..236cbf35b --- /dev/null +++ b/ui/v2.5/src/hooks/debounce.ts @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react-hooks/exhaustive-deps */ +import { DebounceSettings } from "lodash-es"; +import debounce, { DebouncedFunc } from "lodash-es/debounce"; +import React, { useCallback } from "react"; + +export function useDebounce any>( + fn: T, + deps: React.DependencyList, + wait?: number, + options?: DebounceSettings +): DebouncedFunc { + return useCallback(debounce(fn, wait, options), [...deps, wait, options]); +} + +// Convenience hook for use with state setters +export function useDebouncedSetState( + fn: React.Dispatch>, + wait?: number, + options?: DebounceSettings +): DebouncedFunc>> { + return useDebounce(fn, [], wait, options); +}