Skip to content

Make formik sync when validation functions are sync #3976

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
249 changes: 141 additions & 108 deletions packages/formik/src/Formik.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,32 +195,31 @@ export function useFormik<Values extends FormikValues = FormikValues>({
}, []);

const runValidateHandler = React.useCallback(
(values: Values, field?: string): Promise<FormikErrors<Values>> => {
return new Promise((resolve, reject) => {
const maybePromisedErrors = (props.validate as any)(values, field);
if (maybePromisedErrors == null) {
// use loose null check here on purpose
resolve(emptyErrors);
} else if (isPromise(maybePromisedErrors)) {
(maybePromisedErrors as Promise<any>).then(
errors => {
resolve(errors || emptyErrors);
},
actualException => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Warning: An unhandled error was caught during validation in <Formik validate />`,
actualException
);
}

reject(actualException);
(
values: Values,
field?: string
): FormikErrors<Values> | Promise<FormikErrors<Values>> => {
const maybePromisedErrors = props.validate?.(values, field);
if (!maybePromisedErrors) {
// use loose null check here on purpose
return emptyErrors;
} else if (isPromise(maybePromisedErrors)) {
return maybePromisedErrors.then(
errors => errors || emptyErrors,
actualException => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Warning: An unhandled error was caught during validation in <Formik validate />`,
actualException
);
}
);
} else {
resolve(maybePromisedErrors);
}
});

return Promise.reject(actualException);
}
);
} else {
return maybePromisedErrors;
}
},
[props.validate]
);
Expand Down Expand Up @@ -269,29 +268,24 @@ export function useFormik<Values extends FormikValues = FormikValues>({
);

const runSingleFieldLevelValidation = React.useCallback(
(field: string, value: void | string): Promise<string> => {
return new Promise(resolve =>
resolve(fieldRegistry.current[field].validate(value) as string)
);
(field: string, value: void | string) => {
return fieldRegistry.current[field].validate(value);
},
[]
);

const runFieldLevelValidations = React.useCallback(
(values: Values): Promise<FormikErrors<Values>> => {
(values: Values): FormikErrors<Values> | Promise<FormikErrors<Values>> => {
const fieldKeysWithValidation: string[] = Object.keys(
fieldRegistry.current
).filter(f => isFunction(fieldRegistry.current[f].validate));

// Construct an array with all of the field validation functions
const fieldValidations: Promise<string>[] =
fieldKeysWithValidation.length > 0
? fieldKeysWithValidation.map(f =>
runSingleFieldLevelValidation(f, getIn(values, f))
)
: [Promise.resolve('DO_NOT_DELETE_YOU_WILL_BE_FIRED')]; // use special case ;)
const fieldValidations = fieldKeysWithValidation.map(f =>
runSingleFieldLevelValidation(f, getIn(values, f))
);

return Promise.all(fieldValidations).then((fieldErrorsList: string[]) =>
const processFieldErrors = (fieldErrorsList: (string | undefined)[]) =>
fieldErrorsList.reduce((prev, curr, index) => {
if (curr === 'DO_NOT_DELETE_YOU_WILL_BE_FIRED') {
return prev;
Expand All @@ -300,26 +294,43 @@ export function useFormik<Values extends FormikValues = FormikValues>({
prev = setIn(prev, fieldKeysWithValidation[index], curr);
}
return prev;
}, {})
);
}, {} as FormikErrors<Values>);

if (fieldValidations.some(isPromise)) {
return Promise.all(fieldValidations).then(processFieldErrors);
} else {
return processFieldErrors(fieldValidations as (string | undefined)[]);
}
},
[runSingleFieldLevelValidation]
);

// Run all validations and return the result
const runAllValidations = React.useCallback(
(values: Values) => {
return Promise.all([
const allValidations = [
runFieldLevelValidations(values),
props.validationSchema ? runValidationSchema(values) : {},
props.validate ? runValidateHandler(values) : {},
]).then(([fieldErrors, schemaErrors, validateErrors]) => {
];

const processAllValidations = ([
fieldErrors,
schemaErrors,
validateErrors,
]: FormikErrors<Values>[]) => {
const combinedErrors = deepmerge.all<FormikErrors<Values>>(
[fieldErrors, schemaErrors, validateErrors],
{ arrayMerge }
);
return combinedErrors;
});
};

if (allValidations.some(isPromise)) {
return Promise.all(allValidations).then(processAllValidations);
} else {
return processAllValidations(allValidations);
}
},
[
props.validate,
Expand All @@ -334,13 +345,21 @@ export function useFormik<Values extends FormikValues = FormikValues>({
const validateFormWithHighPriority = useEventCallback(
(values: Values = state.values) => {
dispatch({ type: 'SET_ISVALIDATING', payload: true });
return runAllValidations(values).then(combinedErrors => {

const processCombinedErrors = (combinedErrors: FormikErrors<Values>) => {
if (!!isMounted.current) {
dispatch({ type: 'SET_ISVALIDATING', payload: false });
dispatch({ type: 'SET_ERRORS', payload: combinedErrors });
}
return combinedErrors;
});
};

const maybePromisedErrors = runAllValidations(values);
if (isPromise(maybePromisedErrors)) {
return maybePromisedErrors.then(processCombinedErrors);
} else {
return processCombinedErrors(maybePromisedErrors);
}
}
);

Expand Down Expand Up @@ -418,7 +437,12 @@ export function useFormik<Values extends FormikValues = FormikValues>({
dispatchFn();
}
},
[props.initialErrors, props.initialStatus, props.initialTouched, props.onReset]
[
props.initialErrors,
props.initialStatus,
props.initialTouched,
props.onReset,
]
);

React.useEffect(() => {
Expand Down Expand Up @@ -739,67 +763,73 @@ export function useFormik<Values extends FormikValues = FormikValues>({

const submitForm = useEventCallback(() => {
dispatch({ type: 'SUBMIT_ATTEMPT' });
return validateFormWithHighPriority().then(
(combinedErrors: FormikErrors<Values>) => {
// In case an error was thrown and passed to the resolved Promise,
// `combinedErrors` can be an instance of an Error. We need to check
// that and abort the submit.
// If we don't do that, calling `Object.keys(new Error())` yields an
// empty array, which causes the validation to pass and the form
// to be submitted.

const isInstanceOfError = combinedErrors instanceof Error;
const isActuallyValid =
!isInstanceOfError && Object.keys(combinedErrors).length === 0;
if (isActuallyValid) {
// Proceed with submit...
//
// To respect sync submit fns, we can't simply wrap executeSubmit in a promise and
// _always_ dispatch SUBMIT_SUCCESS because isSubmitting would then always be false.
// This would be fine in simple cases, but make it impossible to disable submit
// buttons where people use callbacks or promises as side effects (which is basically
// all of v1 Formik code). Instead, recall that we are inside of a promise chain already,
// so we can try/catch executeSubmit(), if it returns undefined, then just bail.
// If there are errors, throw em. Otherwise, wrap executeSubmit in a promise and handle
// cleanup of isSubmitting on behalf of the consumer.
let promiseOrUndefined;
try {
promiseOrUndefined = executeSubmit();
// Bail if it's sync, consumer is responsible for cleaning up
// via setSubmitting(false)
if (promiseOrUndefined === undefined) {
return;
}
} catch (error) {
throw error;
}

return Promise.resolve(promiseOrUndefined)
.then(result => {
if (!!isMounted.current) {
dispatch({ type: 'SUBMIT_SUCCESS' });
}
return result;
})
.catch(_errors => {
if (!!isMounted.current) {
dispatch({ type: 'SUBMIT_FAILURE' });
// This is a legit error rejected by the onSubmit fn
// so we don't want to break the promise chain
throw _errors;
}
});
} else if (!!isMounted.current) {
// ^^^ Make sure Formik is still mounted before updating state
dispatch({ type: 'SUBMIT_FAILURE' });
// throw combinedErrors;
if (isInstanceOfError) {
throw combinedErrors;
const processErrors = (combinedErrors: FormikErrors<Values>) => {
// In case an error was thrown and passed to the resolved Promise,
// `combinedErrors` can be an instance of an Error. We need to check
// that and abort the submit.
// If we don't do that, calling `Object.keys(new Error())` yields an
// empty array, which causes the validation to pass and the form
// to be submitted.

const isInstanceOfError = combinedErrors instanceof Error;
const isActuallyValid =
!isInstanceOfError && Object.keys(combinedErrors).length === 0;
if (isActuallyValid) {
// Proceed with submit...
//
// To respect sync submit fns, we can't simply wrap executeSubmit in a promise and
// _always_ dispatch SUBMIT_SUCCESS because isSubmitting would then always be false.
// This would be fine in simple cases, but make it impossible to disable submit
// buttons where people use callbacks or promises as side effects (which is basically
// all of v1 Formik code). Instead, recall that we are inside of a promise chain already,
// so we can try/catch executeSubmit(), if it returns undefined, then just bail.
// If there are errors, throw em. Otherwise, wrap executeSubmit in a promise and handle
// cleanup of isSubmitting on behalf of the consumer.
let promiseOrUndefined;
try {
promiseOrUndefined = executeSubmit();
// Bail if it's sync, consumer is responsible for cleaning up
// via setSubmitting(false)
if (promiseOrUndefined === undefined) {
return;
}
} catch (error) {
throw error;
}

return Promise.resolve(promiseOrUndefined)
.then(result => {
if (!!isMounted.current) {
dispatch({ type: 'SUBMIT_SUCCESS' });
}
return result;
})
.catch(_errors => {
if (!!isMounted.current) {
dispatch({ type: 'SUBMIT_FAILURE' });
// This is a legit error rejected by the onSubmit fn
// so we don't want to break the promise chain
throw _errors;
}
});
} else if (!!isMounted.current) {
// ^^^ Make sure Formik is still mounted before updating state
dispatch({ type: 'SUBMIT_FAILURE' });
// throw combinedErrors;
if (isInstanceOfError) {
throw combinedErrors;
}
return;
}
);
return;
};

const maybePromisedErrors = validateFormWithHighPriority();
if (isPromise(maybePromisedErrors)) {
return maybePromisedErrors.then(processErrors);
} else {
return processErrors(maybePromisedErrors);
}
});

const handleSubmit = useEventCallback(
Expand Down Expand Up @@ -831,12 +861,15 @@ export function useFormik<Values extends FormikValues = FormikValues>({
}
}

submitForm().catch(reason => {
console.warn(
`Warning: An unhandled error was caught from submitForm()`,
reason
);
});
const maybePromise = submitForm();
if (isPromise(maybePromise)) {
maybePromise.catch(reason => {
console.warn(
`Warning: An unhandled error was caught from submitForm()`,
reason
);
});
}
}
);

Expand Down
Loading
Loading