diff --git a/.changeset/make-formik-possibly-synchronous.md b/.changeset/make-formik-possibly-synchronous.md new file mode 100644 index 000000000..98fac11fc --- /dev/null +++ b/.changeset/make-formik-possibly-synchronous.md @@ -0,0 +1,5 @@ +--- +'formik': minor +--- + +Formik might not need to run asynchronously, it all depends on the validation functions. This change makes it all run synchronously if the validation functions do not return any promise, making all the updates much faster and creating les re-renders. \ No newline at end of file diff --git a/packages/formik/src/Formik.tsx b/packages/formik/src/Formik.tsx index ea36e80d3..1843cfbe0 100755 --- a/packages/formik/src/Formik.tsx +++ b/packages/formik/src/Formik.tsx @@ -164,14 +164,6 @@ export function useFormik({ }, []); } - React.useEffect(() => { - isMounted.current = true; - - return () => { - isMounted.current = false; - }; - }, []); - const [, setIteration] = React.useState(0); const stateRef = React.useRef>({ values: cloneDeep(props.initialValues), @@ -195,32 +187,31 @@ export function useFormik({ }, []); const runValidateHandler = React.useCallback( - (values: Values, field?: string): Promise> => { - 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).then( - errors => { - resolve(errors || emptyErrors); - }, - actualException => { - if (process.env.NODE_ENV !== 'production') { - console.warn( - `Warning: An unhandled error was caught during validation in `, - actualException - ); - } - - reject(actualException); + ( + values: Values, + field?: string + ): FormikErrors | Promise> => { + 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 `, + actualException + ); } - ); - } else { - resolve(maybePromisedErrors); - } - }); + + return Promise.reject(actualException); + } + ); + } else { + return maybePromisedErrors; + } }, [props.validate] ); @@ -269,30 +260,25 @@ export function useFormik({ ); const runSingleFieldLevelValidation = React.useCallback( - (field: string, value: void | string): Promise => { - 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> => { + (values: Values): FormikErrors | Promise> => { 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[] = - 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[]) => - fieldErrorsList.reduce((prev, curr, index) => { + const processFieldErrors = (fieldErrorsList: (string | undefined)[]) => + fieldErrorsList.reduce>((prev, curr, index) => { if (curr === 'DO_NOT_DELETE_YOU_WILL_BE_FIRED') { return prev; } @@ -300,8 +286,13 @@ export function useFormik({ prev = setIn(prev, fieldKeysWithValidation[index], curr); } return prev; - }, {}) - ); + }, {}); + + if (fieldValidations.some(isPromise)) { + return Promise.all(fieldValidations).then(processFieldErrors); + } else { + return processFieldErrors(fieldValidations as (string | undefined)[]); + } }, [runSingleFieldLevelValidation] ); @@ -309,17 +300,29 @@ export function useFormik({ // 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[]) => { const combinedErrors = deepmerge.all>( [fieldErrors, schemaErrors, validateErrors], { arrayMerge } ); return combinedErrors; - }); + }; + + if (allValidations.some(isPromise)) { + return Promise.all(allValidations).then(processAllValidations); + } else { + return processAllValidations(allValidations); + } }, [ props.validate, @@ -334,25 +337,23 @@ export function useFormik({ const validateFormWithHighPriority = useEventCallback( (values: Values = state.values) => { dispatch({ type: 'SET_ISVALIDATING', payload: true }); - return runAllValidations(values).then(combinedErrors => { + + const processCombinedErrors = (combinedErrors: FormikErrors) => { if (!!isMounted.current) { dispatch({ type: 'SET_ISVALIDATING', payload: false }); dispatch({ type: 'SET_ERRORS', payload: combinedErrors }); } return combinedErrors; - }); - } - ); + }; - React.useEffect(() => { - if ( - validateOnMount && - isMounted.current === true && - isEqual(initialValues.current, props.initialValues) - ) { - validateFormWithHighPriority(initialValues.current); + const maybePromisedErrors = runAllValidations(values); + if (isPromise(maybePromisedErrors)) { + return maybePromisedErrors.then(processCombinedErrors); + } else { + return processCombinedErrors(maybePromisedErrors); + } } - }, [validateOnMount, validateFormWithHighPriority]); + ); const resetForm = React.useCallback( (nextState?: Partial>) => { @@ -418,71 +419,75 @@ export function useFormik({ dispatchFn(); } }, - [props.initialErrors, props.initialStatus, props.initialTouched, props.onReset] + [ + props.initialErrors, + props.initialStatus, + props.initialTouched, + props.onReset, + ] ); + // Grouping the effects to it's easier to predict when they run and synchronizing setting isMounted to true, validation on mount and reinitialization React.useEffect(() => { - if ( - isMounted.current === true && - !isEqual(initialValues.current, props.initialValues) - ) { - if (enableReinitialize) { + if (isMounted.current === false) { + isMounted.current = true; + + if ( + validateOnMount && + isEqual(initialValues.current, props.initialValues) + ) { + validateFormWithHighPriority(initialValues.current); + } + } else if (enableReinitialize) { + if (!isEqual(initialValues.current, props.initialValues)) { initialValues.current = props.initialValues; resetForm(); if (validateOnMount) { validateFormWithHighPriority(initialValues.current); } } + if (!isEqual(initialErrors.current, props.initialErrors || emptyErrors)) { + initialErrors.current = props.initialErrors || emptyErrors; + dispatch({ + type: 'SET_ERRORS', + payload: props.initialErrors || emptyErrors, + }); + } + if ( + !isEqual(initialTouched.current, props.initialTouched || emptyTouched) + ) { + initialTouched.current = props.initialTouched || emptyTouched; + dispatch({ + type: 'SET_TOUCHED', + payload: props.initialTouched || emptyTouched, + }); + } + if (!isEqual(initialStatus.current, props.initialStatus)) { + initialStatus.current = props.initialStatus; + dispatch({ + type: 'SET_STATUS', + payload: props.initialStatus, + }); + } } }, [ enableReinitialize, props.initialValues, + props.initialErrors, + props.initialStatus, + props.initialTouched, resetForm, - validateOnMount, validateFormWithHighPriority, + validateOnMount, ]); - - React.useEffect(() => { - if ( - enableReinitialize && - isMounted.current === true && - !isEqual(initialErrors.current, props.initialErrors) - ) { - initialErrors.current = props.initialErrors || emptyErrors; - dispatch({ - type: 'SET_ERRORS', - payload: props.initialErrors || emptyErrors, - }); - } - }, [enableReinitialize, props.initialErrors]); - - React.useEffect(() => { - if ( - enableReinitialize && - isMounted.current === true && - !isEqual(initialTouched.current, props.initialTouched) - ) { - initialTouched.current = props.initialTouched || emptyTouched; - dispatch({ - type: 'SET_TOUCHED', - payload: props.initialTouched || emptyTouched, - }); - } - }, [enableReinitialize, props.initialTouched]); - - React.useEffect(() => { - if ( - enableReinitialize && - isMounted.current === true && - !isEqual(initialStatus.current, props.initialStatus) - ) { - initialStatus.current = props.initialStatus; - dispatch({ - type: 'SET_STATUS', - payload: props.initialStatus, - }); - } - }, [enableReinitialize, props.initialStatus, props.initialTouched]); + // Splitting the mounting reset to its own effect so it is only ever called once: when the component really unmounts + // Otherwise it is also called when the effect's dependencies change making the value go back to `false` + React.useEffect( + () => () => { + isMounted.current = false; + }, + [] + ); const validateField = useEventCallback((name: string) => { // This will efficiently validate a single field by avoiding state @@ -512,10 +517,10 @@ export function useFormik({ type: 'SET_FIELD_ERROR', payload: { field: name, - value: maybePromise as string | undefined, + value: maybePromise, }, }); - return Promise.resolve(maybePromise as string | undefined); + return maybePromise; } } else if (props.validationSchema) { dispatch({ type: 'SET_ISVALIDATING', payload: true }); @@ -530,7 +535,7 @@ export function useFormik({ }); } - return Promise.resolve(); + return; }); const registerField = React.useCallback((name: string, { validate }: any) => { @@ -741,67 +746,73 @@ export function useFormik({ const submitForm = useEventCallback(() => { dispatch({ type: 'SUBMIT_ATTEMPT' }); - return validateFormWithHighPriority().then( - (combinedErrors: FormikErrors) => { - // 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) => { + // 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( @@ -833,12 +844,15 @@ export function useFormik({ } } - 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 + ); + }); + } } ); diff --git a/packages/formik/src/types.tsx b/packages/formik/src/types.tsx index 71db6792c..b0ea6ee15 100644 --- a/packages/formik/src/types.tsx +++ b/packages/formik/src/types.tsx @@ -86,18 +86,18 @@ export interface FormikHelpers { setTouched: ( touched: FormikTouched, shouldValidate?: boolean - ) => Promise>; + ) => Promise> | FormikErrors | void; /** Manually set values object */ setValues: ( values: React.SetStateAction, shouldValidate?: boolean - ) => Promise>; + ) => Promise> | FormikErrors | void; /** Set value of form field directly */ setFieldValue: ( field: string, value: any, shouldValidate?: boolean - ) => Promise>; + ) => Promise> | FormikErrors | void; /** Set error message of a form field directly */ setFieldError: (field: string, message: string | undefined) => void; /** Set whether field has been touched directly */ @@ -105,15 +105,19 @@ export interface FormikHelpers { field: string, isTouched?: boolean, shouldValidate?: boolean - ) => Promise>; + ) => Promise> | FormikErrors | void; /** Validate form values */ - validateForm: (values?: any) => Promise>; + validateForm: ( + values?: any + ) => FormikErrors | Promise>; /** Validate field value */ - validateField: (field: string) => Promise | Promise; + validateField: ( + field: string + ) => Promise | string | undefined | void; /** Reset form */ resetForm: (nextState?: Partial>) => void; /** Submit the form imperatively */ - submitForm: () => Promise; + submitForm: () => Promise | void; /** Set Formik state, careful! */ setFormikState: ( f: @@ -232,7 +236,10 @@ export interface FormikConfig extends FormikSharedConfig { * Validation function. Must return an error object or promise that * throws an error object where that object keys map to corresponding value. */ - validate?: (values: Values) => void | object | Promise>; + validate?: ( + values: Values, + field?: string + ) => void | object | Promise>; /** Inner ref */ innerRef?: React.Ref>; @@ -247,7 +254,7 @@ export type FormikProps = FormikSharedConfig & FormikHelpers & FormikHandlers & FormikComputedProps & - FormikRegistration & { submitForm: () => Promise }; + FormikRegistration & { submitForm: () => Promise | any }; /** Internal Formik registration methods that get passed down as props */ export interface FormikRegistration { @@ -302,9 +309,15 @@ export interface FieldMetaProps { /** Imperative handles to change a field's value, error and touched */ export interface FieldHelperProps { /** Set the field's value */ - setValue: (value: Value, shouldValidate?: boolean) => Promise>; + setValue: ( + value: Value, + shouldValidate?: boolean + ) => Promise> | FormikErrors | void; /** Set the field's touched value */ - setTouched: (value: boolean, shouldValidate?: boolean) => Promise>; + setTouched: ( + value: boolean, + shouldValidate?: boolean + ) => Promise> | FormikErrors | void; /** Set the field's error value */ setError: (value: string | undefined) => void; } diff --git a/packages/formik/src/utils.ts b/packages/formik/src/utils.ts index e6d65b9a9..f46cb495b 100755 --- a/packages/formik/src/utils.ts +++ b/packages/formik/src/utils.ts @@ -33,7 +33,7 @@ export const isEmptyChildren = (children: any): boolean => React.Children.count(children) === 0; /** @private is the given object/value a promise? */ -export const isPromise = (value: any): value is PromiseLike => +export const isPromise = (value: T | Promise): value is Promise => isObject(value) && isFunction(value.then); /** @private is the given object/value a type of synthetic event? */ diff --git a/packages/formik/test/Formik.test.tsx b/packages/formik/test/Formik.test.tsx index 864589643..836bb503e 100644 --- a/packages/formik/test/Formik.test.tsx +++ b/packages/formik/test/Formik.test.tsx @@ -36,6 +36,7 @@ function Form({ }: FormikProps) { return (
+ {touched.name && errors.name &&
{errors.name}
} {isSubmitting &&
Submitting
} @@ -97,12 +100,13 @@ function renderFormik( return injected; }, ...rest, - rerender: () => + rerender: (newProps?: Partial>) => rerender( {formikProps => (injected = formikProps) && ( @@ -128,6 +132,365 @@ describe('', () => { expect(props.submitCount).toBe(0); }); + describe('validation', () => { + it('does not validate the form on mount by default', async () => { + const validate = () => ({ name: 'This field is required' }); + const { getProps } = renderFormik({ validate }); + + expect(getProps().errors).toEqual({}); + }); + + describe('When the validation on mount is enabled', () => { + it('validates the form on mount', async () => { + const validate = () => ({ name: 'This field is required' }); + const { getProps } = renderFormik({ validate, validateOnMount: true }); + + expect(getProps().errors).toEqual({ name: 'This field is required' }); + }); + }); + }); + + describe('reinitialization', () => { + describe('when the setting is enabled', () => { + describe(`and the initial values are changed`, () => { + it('should reinitialize the form', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialValues: { name: 'jared' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({}); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialValues: { name: 'other' } }); + + expect(getProps().values.name).toEqual('other'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({}); + }); + }); + + describe(`and the initial values are changed for the same object with a different reference`, () => { + it('should not reinitialize the form', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialValues: { name: 'jared' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({}); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialValues: { name: 'jared' } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + }); + }); + + describe(`and the initial errors are changed`, () => { + it('should reinitialize the form errors only', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialErrors: { name: 'Some custom error' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({ name: 'Some custom error' }); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialErrors: { name: 'Some other error' } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ name: 'Some other error' }); + }); + }); + + describe(`and the initial errors are changed for the same object with a different reference`, () => { + it('should not reinitialize the form', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialErrors: { name: 'Some custom error' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({ + name: 'Some custom error', + }); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialErrors: { name: 'Some custom error' } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + }); + }); + + describe(`and the initial touched fields are changed`, () => { + it('should reinitialize the form touched fields only', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialTouched: { name: true }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({}); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialTouched: { name: false } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: false }); + expect(getProps().errors).toEqual({ name: 'This field is required' }); + }); + }); + + describe(`and the initial touched fields are changed for the same object with a different reference`, () => { + it('should not reinitialize the form', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialTouched: { name: false }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({ name: false }); + expect(getProps().errors).toEqual({}); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + + rerender({ initialTouched: { name: false } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + }); + }); + + describe(`and the initial status are changed`, () => { + it('should reinitialize the form status only', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialStatus: { name: 'status 1' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({}); + expect(getProps().status).toEqual({ name: 'status 1' }); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + act(() => getProps().setStatus({ name: 'status 3' })); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + expect(getProps().status).toEqual({ name: 'status 3' }); + + rerender({ initialStatus: { name: 'status 2' } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ name: 'This field is required' }); + expect(getProps().status).toEqual({ name: 'status 2' }); + }); + }); + + describe(`and the initial touched fields are changed for the same object with a different reference`, () => { + it('should not reinitialize the form', async () => { + const { getProps, rerender, getByTestId } = renderFormik({ + enableReinitialize: true, + initialStatus: { name: 'status 1' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().values.name).toEqual('jared'); + expect(getProps().touched).toEqual({}); + expect(getProps().errors).toEqual({}); + expect(getProps().status).toEqual({ name: 'status 1' }); + + const input = getByTestId('name-input'); + fireEvent.change(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + fireEvent.blur(input, { + persist: noop, + target: { name: 'name', value: '' }, + }); + act(() => getProps().setStatus({ name: 'status 3' })); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + expect(getProps().status).toEqual({ name: 'status 3' }); + + rerender({ initialStatus: { name: 'status 1' } }); + + expect(getProps().values.name).toEqual(''); + expect(getProps().touched).toEqual({ name: true }); + expect(getProps().errors).toEqual({ + name: 'This field is required', + }); + expect(getProps().status).toEqual({ name: 'status 3' }); + }); + }); + + describe('and the data is validated on mount', () => { + it('the errors are correctly set on the form', async () => { + const { getProps } = renderFormik({ + enableReinitialize: true, + validateOnMount: true, + initialValues: { name: '' }, + validate: (values: any) => ({ + name: values.name ? undefined : 'This field is required', + }), + }); + + expect(getProps().errors).toEqual({ name: 'This field is required' }); + }); + }); + }); + }); + describe('handleChange', () => { it('updates values based on name attribute', () => { const { getProps, getByTestId } = renderFormik(); @@ -455,7 +818,7 @@ describe('', () => { }).not.toThrow(); }); - it('should not error if onSubmit throws an error', () => { + it('should not error if onSubmit throws an error', async () => { const FormNoPreventDefault = ( ', () => { expect(() => { fireEvent.click(screen.getByTestId('submit-button')); }).not.toThrow(); + + // Wait for the form submission to finish + await act(() => Promise.resolve()); }); it('should touch all fields', () => { @@ -503,6 +869,76 @@ describe('', () => { expect(validate).toHaveBeenCalled(); }); + it('should run the form validation synchronously', () => { + const validate = jest.fn(() => ({})); + const { getProps } = renderFormik({ validate }); + + const { validateForm } = getProps(); + + let formValidationResult: ReturnType; + act(() => { + formValidationResult = validateForm(); + }); + + expect(formValidationResult!).not.toBeInstanceOf(Promise); + }); + + it('should set field value synchronously', () => { + const validate = jest.fn(() => ({})); + const { getProps } = renderFormik({ validate }); + + const { setFieldValue } = getProps(); + + let result: ReturnType; + act(() => { + result = setFieldValue('name', 'ian'); + }); + + expect(result!).not.toBeInstanceOf(Promise); + }); + + it('should set field touched synchronously', () => { + const validate = jest.fn(() => ({})); + const { getProps } = renderFormik({ validate }); + + const { setFieldTouched } = getProps(); + + let result: ReturnType; + act(() => { + result = setFieldTouched('name', true); + }); + + expect(result!).not.toBeInstanceOf(Promise); + }); + + it('should set form values synchronously', () => { + const validate = jest.fn(() => ({})); + const { getProps } = renderFormik({ validate }); + + const { setValues } = getProps(); + + let result: ReturnType; + act(() => { + result = setValues({ name: 'ian', age: 25 }); + }); + + expect(result!).not.toBeInstanceOf(Promise); + }); + + it('should set form touched synchronously', () => { + const validate = jest.fn(() => ({})); + const { getProps } = renderFormik({ validate }); + + const { setTouched } = getProps(); + + let result: ReturnType; + act(() => { + result = setTouched({ name: true, age: false }); + }); + + expect(result!).not.toBeInstanceOf(Promise); + }); + it('should submit the form if valid', async () => { const onSubmit = jest.fn(); const validate = jest.fn(() => ({})); @@ -562,29 +998,109 @@ describe('', () => { }); describe('with validate (ASYNC)', () => { - it('should call validate if present', () => { + it('should call validate if present', async () => { const validate = jest.fn(() => Promise.resolve({})); const { getByTestId } = renderFormik({ validate }); - fireEvent.submit(getByTestId('form')); + await act(async () => fireEvent.submit(getByTestId('form'))); expect(validate).toHaveBeenCalled(); }); + it('should run the form validation asynchronously', async () => { + const validate = jest.fn(() => Promise.resolve({})); + const { getProps } = renderFormik({ validate }); + + const { validateForm } = getProps(); + + let formValidationResult: ReturnType; + act(() => { + formValidationResult = validateForm(); + }); + + expect(formValidationResult!).toBeInstanceOf(Promise); + + await act(() => formValidationResult); + }); + + it('should set field value asynchronously', async () => { + const validate = jest.fn(() => Promise.resolve({})); + const { getProps } = renderFormik({ validate }); + + const { setFieldValue } = getProps(); + + let result: ReturnType; + act(() => { + result = setFieldValue('name', 'ian'); + }); + + expect(result!).toBeInstanceOf(Promise); + + await act(() => result); + }); + + it('should set field touched asynchronously', async () => { + const validate = jest.fn(() => Promise.resolve({})); + const { getProps } = renderFormik({ validate }); + + const { setFieldTouched } = getProps(); + + let result: ReturnType; + act(() => { + result = setFieldTouched('name', true); + }); + + expect(result!).toBeInstanceOf(Promise); + + await act(() => result); + }); + + it('should set form values asynchronously', async () => { + const validate = jest.fn(() => Promise.resolve({})); + const { getProps } = renderFormik({ validate }); + + const { setValues } = getProps(); + + let result: ReturnType; + act(() => { + result = setValues({ name: 'ian', age: 25 }); + }); + + expect(result!).toBeInstanceOf(Promise); + + await act(() => result); + }); + + it('should set form touched asynchronously', async () => { + const validate = jest.fn(() => Promise.resolve({})); + const { getProps } = renderFormik({ validate }); + + const { setTouched } = getProps(); + + let result: ReturnType; + act(() => { + result = setTouched({ name: true, age: false }); + }); + + expect(result!).toBeInstanceOf(Promise); + + await act(() => result); + }); + it('should submit the form if valid', async () => { const onSubmit = jest.fn(); const validate = jest.fn(() => Promise.resolve({})); const { getByTestId } = renderFormik({ onSubmit, validate }); - fireEvent.submit(getByTestId('form')); + await act(async () => fireEvent.submit(getByTestId('form'))); await waitFor(() => expect(onSubmit).toBeCalled()); }); - it('should not submit the form if invalid', () => { + it('should not submit the form if invalid', async () => { const onSubmit = jest.fn(); const validate = jest.fn(() => Promise.resolve({ name: 'Error!' })); const { getByTestId } = renderFormik({ onSubmit, validate }); - fireEvent.submit(getByTestId('form')); + await act(async () => fireEvent.submit(getByTestId('form'))); expect(onSubmit).not.toBeCalled(); }); @@ -1327,9 +1843,11 @@ describe('', () => { it('isValidating is fired when submit is attempted', async () => { const onSubmit = jest.fn(); - const validate = jest.fn(() => ({ - name: 'no', - })); + const validate = jest.fn(() => + Promise.resolve({ + name: 'no', + }) + ); const { getProps } = renderFormik({ onSubmit, @@ -1340,7 +1858,7 @@ describe('', () => { expect(getProps().isSubmitting).toBe(false); expect(getProps().isValidating).toBe(false); - let submitFormPromise: Promise; + let submitFormPromise: void | Promise; act(() => { // we call set isValidating synchronously submitFormPromise = getProps().submitForm(); @@ -1376,7 +1894,7 @@ describe('', () => { expect(getProps().isSubmitting).toBe(false); expect(getProps().isValidating).toBe(false); - let submitFormPromise: Promise; + let submitFormPromise: void | Promise; act(() => { // we call set isValidating synchronously submitFormPromise = getProps().submitForm(); @@ -1411,8 +1929,8 @@ describe('', () => { expect(getProps().submitCount).toEqual(0); expect(getProps().isSubmitting).toBe(false); expect(getProps().isValidating).toBe(false); - let submitFormPromise: Promise; + let submitFormPromise: void | Promise; act(() => { // we call set isValidating synchronously submitFormPromise = getProps().submitForm(); @@ -1436,17 +1954,18 @@ describe('', () => { }); it('isValidating is fired validation is run', async () => { - const validate = jest.fn(() => ({ name: 'no' })); + const validate = jest.fn(() => Promise.resolve({ name: 'no' })); const { getProps } = renderFormik({ validate, }); expect(getProps().isValidating).toBe(false); - let validatePromise: Promise; + const { validateForm } = getProps(); + let validatePromise: ReturnType; act(() => { // we call set isValidating synchronously - validatePromise = getProps().validateForm(); + validatePromise = validateForm(); }); expect(getProps().isValidating).toBe(true); @@ -1545,6 +2064,8 @@ describe('', () => { }, }); - expect(InitialValuesWithNestedObject.content.items[0].cards[0].desc).toEqual('Initial Desc'); + expect( + InitialValuesWithNestedObject.content.items[0].cards[0].desc + ).toEqual('Initial Desc'); }); });