From 574c907e4ecae9b5c5d72bacfa67faa20540ebc9 Mon Sep 17 00:00:00 2001 From: Vit Horacek <36083550+mountiny@users.noreply.github.com> Date: Wed, 27 Nov 2024 22:24:27 +0100 Subject: [PATCH] Merge pull request #53227 from margelo/fix/do-not-call-submit-twice fix: do not call submit function twice if user clicks fast (cherry picked from commit 51a2879021f08bcfad7e9c94cd1a0a595451ff72) (CP triggered by mountiny) --- src/components/Form/FormProvider.tsx | 43 +++++++++++---------- src/hooks/useDebounceNonReactive.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useDebounceNonReactive.ts diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 8baaf0c40576..2731d6bd1f98 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -4,6 +4,7 @@ import type {ForwardedRef, MutableRefObject, ReactNode, RefAttributes} from 'rea import React, {createRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import useDebounceNonReactive from '@hooks/useDebounceNonReactive'; import useLocalize from '@hooks/useLocalize'; import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; @@ -185,30 +186,34 @@ function FormProvider( [touchedInputs], ); - const submit = useCallback(() => { - // Return early if the form is already submitting to avoid duplicate submission - if (formState?.isLoading) { - return; - } + const submit = useDebounceNonReactive( + useCallback(() => { + // Return early if the form is already submitting to avoid duplicate submission + if (formState?.isLoading) { + return; + } - // Prepare values before submitting - const trimmedStringValues = shouldTrimValues ? ValidationUtils.prepareValues(inputValues) : inputValues; + // Prepare values before submitting + const trimmedStringValues = shouldTrimValues ? ValidationUtils.prepareValues(inputValues) : inputValues; - // Touches all form inputs, so we can validate the entire form - Object.keys(inputRefs.current).forEach((inputID) => (touchedInputs.current[inputID] = true)); + // Touches all form inputs, so we can validate the entire form + Object.keys(inputRefs.current).forEach((inputID) => (touchedInputs.current[inputID] = true)); - // Validate form and return early if any errors are found - if (!isEmptyObject(onValidate(trimmedStringValues))) { - return; - } + // Validate form and return early if any errors are found + if (!isEmptyObject(onValidate(trimmedStringValues))) { + return; + } - // Do not submit form if network is offline and the form is not enabled when offline - if (network?.isOffline && !enabledWhenOffline) { - return; - } + // Do not submit form if network is offline and the form is not enabled when offline + if (network?.isOffline && !enabledWhenOffline) { + return; + } - KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); - }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate, shouldTrimValues]); + KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); + }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), + 1000, + {leading: true, trailing: false}, + ); // Keep track of the focus state of the current screen. // This is used to prevent validating the form on blur before it has been interacted with. diff --git a/src/hooks/useDebounceNonReactive.ts b/src/hooks/useDebounceNonReactive.ts new file mode 100644 index 000000000000..755af694bf30 --- /dev/null +++ b/src/hooks/useDebounceNonReactive.ts @@ -0,0 +1,57 @@ +// eslint-disable-next-line lodash/import-scope +import type {DebouncedFunc, DebounceSettings} from 'lodash'; +import lodashDebounce from 'lodash/debounce'; +import {useCallback, useEffect, useRef} from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericFunction = (...args: any[]) => void; + +/** + * Create and return a debounced function. + * + * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). + * This hook doesn't react on function identity changes and will not cancel the debounce in case of function identity change. + * This is important because we want to debounce the function call and not the function reference. + * + * @param func The function to debounce. + * @param wait The number of milliseconds to delay. + * @param options The options object. + * @param options.leading Specify invoking on the leading edge of the timeout. + * @param options.maxWait The maximum time func is allowed to be delayed before it’s invoked. + * @param options.trailing Specify invoking on the trailing edge of the timeout. + * @returns Returns a function to call the debounced function. + */ +export default function useDebounceNonReactive(func: T, wait: number, options?: DebounceSettings): T { + const funcRef = useRef(func); // Store the latest func reference + const debouncedFnRef = useRef>(); + const {leading, maxWait, trailing = true} = options ?? {}; + + useEffect(() => { + // Update the funcRef dynamically to avoid recreating debounce + funcRef.current = func; + }, [func]); + + // Recreate the debounce instance only if debounce settings change + useEffect(() => { + const debouncedFn = lodashDebounce( + (...args: Parameters) => { + funcRef.current(...args); // Use the latest func reference + }, + wait, + {leading, maxWait, trailing}, + ); + + debouncedFnRef.current = debouncedFn; + + return () => { + debouncedFn.cancel(); + }; + }, [wait, leading, maxWait, trailing]); + + const debounceCallback = useCallback((...args: Parameters) => { + debouncedFnRef.current?.(...args); + }, []); + + // eslint-disable-next-line react-compiler/react-compiler + return debounceCallback as T; +}