Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}