Skip to content

Commit

Permalink
Merge pull request #53227 from margelo/fix/do-not-call-submit-twice
Browse files Browse the repository at this point in the history
fix: do not call submit function twice if user clicks fast
(cherry picked from commit 51a2879)

(CP triggered by mountiny)
  • Loading branch information
mountiny authored and OSBotify committed Nov 27, 2024
1 parent 2a117ad commit 574c907
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 19 deletions.
43 changes: 24 additions & 19 deletions src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions src/hooks/useDebounceNonReactive.ts
Original file line number Diff line number Diff line change
@@ -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<T extends GenericFunction>(func: T, wait: number, options?: DebounceSettings): T {
const funcRef = useRef<T>(func); // Store the latest func reference
const debouncedFnRef = useRef<DebouncedFunc<T>>();
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<T>) => {
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<T>) => {
debouncedFnRef.current?.(...args);
}, []);

// eslint-disable-next-line react-compiler/react-compiler
return debounceCallback as T;
}

0 comments on commit 574c907

Please sign in to comment.