diff --git a/.changeset/delay-onmount.md b/.changeset/delay-onmount.md new file mode 100644 index 000000000..6089fb27d --- /dev/null +++ b/.changeset/delay-onmount.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': minor +--- + +delay onMount listeners and validators until defaultValues are provided (not undefined or null) diff --git a/docs/framework/react/guides/async-initial-values.md b/docs/framework/react/guides/async-initial-values.md index 527eb44e1..791827d30 100644 --- a/docs/framework/react/guides/async-initial-values.md +++ b/docs/framework/react/guides/async-initial-values.md @@ -16,36 +16,69 @@ As such, this guide shows you how you can mix-n-match TanStack Form with TanStac ## Basic Usage +TanStack Form automatically handles async initial values by delaying `onMount` listeners and validation until `defaultValues` are provided (i.e. not `undefined` or `null`). + ```tsx import { useForm } from '@tanstack/react-form' import { useQuery } from '@tanstack/react-query' export default function App() { - const {data, isLoading} = useQuery({ + const { data } = useQuery({ queryKey: ['data'], queryFn: async () => { await new Promise((resolve) => setTimeout(resolve, 1000)) - return {firstName: 'FirstName', lastName: "LastName"} - } + return { firstName: 'FirstName', lastName: 'LastName' } + }, }) const form = useForm({ - defaultValues: { - firstName: data?.firstName ?? '', - lastName: data?.lastName ?? '', - }, + defaultValues: data, onSubmit: async ({ value }) => { // Do something with form data console.log(value) }, }) - if (isLoading) return

Loading..

+ // You can show a loading spinner while waiting for data + // The form's onMount validation/listeners will NOT run until data is available + if (!data) { + return

Loading...

+ } return ( - // ... + +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + ( + field.handleChange(e.target.value)} + /> + )} + /> + + +
) } ``` -This will show a loading spinner until the data is fetched, and then it will render the form with the fetched data as the initial values. +In the example above, even though `useForm` is initialized immediately, the form validation and `onMount` effects will effectively "pause" until `data` is populated. This allows you to comfortably render a loading state without triggering premature validation errors or side effects. diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 55293ce2e..4d3e33279 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1127,6 +1127,16 @@ export class FieldApi< formListeners: Record | null> } + /** + * @private + */ + _hasMounted = false + + /** + * @private + */ + _isMounted = false + /** * Initializes a new `FieldApi` instance. */ @@ -1247,6 +1257,8 @@ export class FieldApi< mount = () => { const cleanup = this.store.mount() + this._isMounted = true + if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) { this.form.setFieldValue(this.name, this.options.defaultValue, { dontUpdateMeta: true, @@ -1258,41 +1270,10 @@ export class FieldApi< this.update(this.options as never) - const { onMount } = this.options.validators || {} - - if (onMount) { - const error = this.runValidator({ - validate: onMount, - value: { - value: this.state.value, - fieldApi: this, - validationSource: 'field', - }, - type: 'validate', - }) - if (error) { - this.setMeta( - (prev) => - ({ - ...prev, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - errorMap: { ...prev?.errorMap, onMount: error }, - errorSourceMap: { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - ...prev?.errorSourceMap, - onMount: 'field', - }, - }) as never, - ) - } + return () => { + this._isMounted = false + cleanup() } - - this.options.listeners?.onMount?.({ - value: this.state.value, - fieldApi: this, - }) - - return cleanup } /** @@ -1343,6 +1324,49 @@ export class FieldApi< if (!this.form.getFieldMeta(this.name)) { this.form.setFieldMeta(this.name, this.state.meta) } + + if ( + this._isMounted && + !this._hasMounted && + this.form.options.defaultValues !== undefined && + this.form.options.defaultValues !== null + ) { + this._hasMounted = true + + const { onMount } = this.options.validators || {} + + if (onMount) { + const error = this.runValidator({ + validate: onMount, + value: { + value: this.state.value, + fieldApi: this, + validationSource: 'field', + }, + type: 'validate', + }) + if (error) { + this.setMeta( + (prev) => + ({ + ...prev, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + errorMap: { ...prev?.errorMap, onMount: error }, + errorSourceMap: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...prev?.errorSourceMap, + onMount: 'field', + }, + }) as never, + ) + } + } + + this.options.listeners?.onMount?.({ + value: this.state.value, + fieldApi: this, + }) + } } /** diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff52bbe74..2525b6341 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -992,6 +992,16 @@ export class FormApi< */ private _devtoolsSubmissionOverride: boolean + /** + * @private + */ + _hasMounted = false + + /** + * @private + */ + _isMounted = false + /** * Constructs a new `FormApi` instance with the given form options. */ @@ -1396,11 +1406,11 @@ export class FormApi< formEventClient.emit('form-unmounted', { id: this._formId, }) - } - this.options.listeners?.onMount?.({ formApi: this }) + this._isMounted = false + } - const { onMount } = this.options.validators || {} + this._isMounted = true // broadcast form state for devtools on mounting formEventClient.emit('form-api', { @@ -1409,6 +1419,18 @@ export class FormApi< options: this.options, }) + if ( + this.options.defaultValues === undefined || + this.options.defaultValues === null + ) { + return cleanup + } + + this.options.listeners?.onMount?.({ formApi: this }) + this._hasMounted = true + + const { onMount } = this.options.validators || {} + // if no validation skip if (!onMount) return cleanup @@ -1443,6 +1465,22 @@ export class FormApi< // Options need to be updated first so that when the store is updated, the state is correct for the derived state this.options = options + if ( + this._isMounted && + !this._hasMounted && + options.defaultValues !== undefined && + options.defaultValues !== null + ) { + this.options.listeners?.onMount?.({ formApi: this }) + this._hasMounted = true + + const { onMount } = this.options.validators || {} + + if (onMount) { + this.validateSync('mount') + } + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const shouldUpdateReeval = !!options.transform?.deps?.some( (val, i) => val !== this.prevTransformArray[i], diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx deleted file mode 100644 index b8a9b3971..000000000 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ /dev/null @@ -1,896 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { formOptions } from '@tanstack/form-core' -import { createFormHook, createFormHookContexts } from '../src' - -const { fieldContext, useFieldContext, formContext, useFormContext } = - createFormHookContexts() - -function Test() { - return null -} - -const { useAppForm, withForm, withFieldGroup } = createFormHook({ - fieldComponents: { - Test, - }, - formComponents: { - Test, - }, - fieldContext, - formContext, -}) - -describe('createFormHook', () => { - it('should not break with an infinite type on large schemas', () => { - const ActivityKind0_Names = ['Work', 'Rest', 'OnCall'] as const - type ActivityKind0 = (typeof ActivityKind0_Names)[number] - - enum DayOfWeek { - Monday = 1, - Tuesday, - Wednesday, - Thursday, - Friday, - Saturday, - Sunday, - } - - interface Branding { - __type?: Brand - } - type Branded = T & Branding - type ActivityId = Branded - interface ActivitySelectorFormData { - includeAll: boolean - includeActivityIds: ActivityId[] - includeActivityKinds: Set - excludeActivityIds: ActivityId[] - } - - const GeneratedTypes0Visibility_Names = [ - 'Normal', - 'Advanced', - 'Hidden', - ] as const - type GeneratedTypes0Visibility = - (typeof GeneratedTypes0Visibility_Names)[number] - interface FormValuesBase { - key: string - visibility: GeneratedTypes0Visibility - } - - interface ActivityCountFormValues extends FormValuesBase { - _type: 'ActivityCount' - activitySelector: ActivitySelectorFormData - daysOfWeek: DayOfWeek[] - label: string - } - - interface PlanningTimesFormValues extends FormValuesBase { - _type: 'PlanningTimes' - showTarget: boolean - showPlanned: boolean - showDiff: boolean - } - - type EditorValues = ActivityCountFormValues | PlanningTimesFormValues - interface EditorFormValues { - editors: Record - ordering: string[] - } - - const ExampleUsage = withForm({ - props: { - initialValues: '' as keyof EditorFormValues['editors'], - }, - defaultValues: {} as EditorFormValues, - render: ({ form, initialValues }) => { - return ( -
- - {(field) => { - expectTypeOf(field.state.value).toExtend() - return null - }} - - - - -
- ) - }, - }) - - const ExampleUsage2 = withFieldGroup({ - defaultValues: {} as EditorValues, - render: ({ group }) => { - const test = group.state.values.key - return ( -
- - {(field) => { - expectTypeOf(field.state.value).toExtend() - return null - }} - - - - -
- ) - }, - }) - }) - - it('types should be properly inferred when using formOptions', () => { - type Person = { - firstName: string - lastName: string - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const WithFormComponent = withForm({ - ...formOpts, - render: ({ form }) => { - expectTypeOf(form.state.values).toEqualTypeOf() - return - }, - }) - }) - - it('types should be properly inferred when passing args alongside formOptions', () => { - type Person = { - firstName: string - lastName: string - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const WithFormComponent = withForm({ - ...formOpts, - onSubmitMeta: { - test: 'test', - }, - render: ({ form }) => { - expectTypeOf(form.handleSubmit).toEqualTypeOf<{ - (): Promise - (submitMeta: { test: string }): Promise - }> - return - }, - }) - }) - - it('types should be properly inferred when formOptions are being overridden', () => { - type Person = { - firstName: string - lastName: string - } - - type PersonWithAge = Person & { - age: number - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const WithFormComponent = withForm({ - ...formOpts, - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - age: 10, - }, - render: ({ form }) => { - expectTypeOf(form.state.values).toExtend() - return - }, - }) - }) - - it('withForm props should be properly inferred', () => { - const WithFormComponent = withForm({ - props: { - prop1: 'test', - prop2: 10, - }, - render: ({ form, ...props }) => { - expectTypeOf(props).toEqualTypeOf<{ - prop1: string - prop2: number - children?: React.ReactNode - }>() - - return - }, - }) - }) - - it('component made from withForm should have its props properly typed', () => { - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - }, - }) - - const appForm = useAppForm(formOpts) - - const WithFormComponent = withForm({ - ...formOpts, - props: { - prop1: 'test', - prop2: 10, - }, - render: ({ form, children, ...props }) => { - expectTypeOf(props).toEqualTypeOf<{ - prop1: string - prop2: number - }>() - return - }, - }) - - const CorrectComponent = ( - - ) - - // @ts-expect-error Missing required props prop1 and prop2 - const MissingPropsComponent = - - const incorrectFormOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - firstNameWrong: 'FirstName', - lastNameWrong: 'LastName', - }, - }) - - const incorrectAppForm = useAppForm(incorrectFormOpts) - - const IncorrectFormOptsComponent = ( - // @ts-expect-error Incorrect form opts - - ) - }) - - it('should infer subset values and props when calling withFieldGroup', () => { - type Person = { - firstName: string - lastName: string - } - type ComponentProps = { - prop1: string - prop2: number - } - - const defaultValues: Person = { - firstName: 'FirstName', - lastName: 'LastName', - } - - const FormGroupComponent = withFieldGroup({ - defaultValues, - render: function Render({ group, children, ...props }) { - // Existing types may be inferred - expectTypeOf(group.state.values.firstName).toEqualTypeOf() - expectTypeOf(group.state.values.lastName).toEqualTypeOf() - - expectTypeOf(group.state.values).toEqualTypeOf() - expectTypeOf(children).toEqualTypeOf() - expectTypeOf(props).toEqualTypeOf<{}>() - return - }, - }) - - const FormGroupComponentWithProps = withFieldGroup({ - ...defaultValues, - props: {} as ComponentProps, - render: ({ group, children, ...props }) => { - expectTypeOf(props).toEqualTypeOf<{ - prop1: string - prop2: number - }>() - return - }, - }) - }) - - it('should allow spreading formOptions when calling withFieldGroup', () => { - type Person = { - firstName: string - lastName: string - } - - const defaultValues: Person = { - firstName: '', - lastName: '', - } - const formOpts = formOptions({ - defaultValues, - validators: { - onChange: () => 'Error', - }, - listeners: { - onBlur: () => 'Something', - }, - asyncAlways: true, - asyncDebounceMs: 500, - }) - - // validators and listeners are ignored, only defaultValues is acknowledged - const FormGroupComponent = withFieldGroup({ - ...formOpts, - render: function Render({ group }) { - // Existing types may be inferred - expectTypeOf(group.state.values.firstName).toEqualTypeOf() - expectTypeOf(group.state.values.lastName).toEqualTypeOf() - return - }, - }) - - const noDefaultValuesFormOpts = formOptions({ - onSubmitMeta: { foo: '' }, - }) - - const UnknownFormGroupComponent = withFieldGroup({ - ...noDefaultValuesFormOpts, - render: function Render({ group }) { - // group.state.values can be anything. - // note that T extends unknown !== unknown extends T. - expectTypeOf().toExtend() - - // either no submit meta or of the type in formOptions - expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf< - [] | [{ foo: string }] - >() - return - }, - }) - }) - - it('should allow passing compatible forms to withFieldGroup', () => { - type Person = { - firstName: string - lastName: string - } - type ComponentProps = { - prop1: string - prop2: number - } - - const defaultValues: Person = { - firstName: 'FirstName', - lastName: 'LastName', - } - - const FormGroup = withFieldGroup({ - defaultValues, - props: {} as ComponentProps, - render: () => { - return <> - }, - }) - - const equalAppForm = useAppForm({ - defaultValues, - }) - - // ----------------- - // Assert that an equal form is not compatible as you have no name to pass - const NoSubfield = ( - - ) - - // ----------------- - // Assert that a form extending Person in a property is allowed - - const extendedAppForm = useAppForm({ - defaultValues: { person: { ...defaultValues, address: '' }, address: '' }, - }) - // While it has other properties, it satisfies defaultValues - const CorrectComponent1 = ( - - ) - - const MissingProps = ( - // @ts-expect-error because prop1 and prop2 are not added - - ) - - // ----------------- - // Assert that a form not satisfying Person errors - const incompatibleAppForm = useAppForm({ - defaultValues: { person: { ...defaultValues, lastName: 0 } }, - }) - const IncompatibleComponent = ( - - ) - }) - - it('should require strict equal submitMeta if it is set in withFieldGroup', () => { - type Person = { - firstName: string - lastName: string - } - type SubmitMeta = { - correct: string - } - - const defaultValues = { - person: { firstName: 'FirstName', lastName: 'LastName' } as Person, - } - const onSubmitMeta: SubmitMeta = { - correct: 'Prop', - } - - const FormLensNoMeta = withFieldGroup({ - defaultValues: {} as Person, - render: function Render({ group }) { - // Since handleSubmit always allows to submit without meta, this is okay - group.handleSubmit() - - // To prevent unwanted meta behaviour, handleSubmit's meta should be never if not set. - expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf< - [] | [submitMeta: never] - >() - - return - }, - }) - - const FormGroupWithMeta = withFieldGroup({ - defaultValues: {} as Person, - onSubmitMeta, - render: function Render({ group }) { - // Since handleSubmit always allows to submit without meta, this is okay - group.handleSubmit() - - // This matches the value - group.handleSubmit({ correct: '' }) - - // This does not. - // @ts-expect-error - group.handleSubmit({ wrong: 'Meta' }) - - return - }, - }) - - const noMetaForm = useAppForm({ - defaultValues, - }) - - const CorrectComponent1 = ( - - ) - - const WrongComponent1 = ( - - ) - - const metaForm = useAppForm({ - defaultValues, - onSubmitMeta, - }) - - const CorrectComponent2 = - const CorrectComponent3 = ( - - ) - - const diffMetaForm = useAppForm({ - defaultValues, - onSubmitMeta: { ...onSubmitMeta, something: 'else' }, - }) - - const CorrectComponent4 = ( - - ) - const WrongComponent2 = ( - - ) - }) - - it('should accept any validators for withFieldGroup', () => { - type Person = { - firstName: string - lastName: string - } - - const defaultValues = { - person: { firstName: 'FirstName', lastName: 'LastName' } satisfies Person, - } - - const formA = useAppForm({ - defaultValues, - validators: { - onChange: () => 'A', - }, - listeners: { - onChange: () => 'A', - }, - }) - const formB = useAppForm({ - defaultValues, - validators: { - onChange: () => 'B', - }, - listeners: { - onChange: () => 'B', - }, - }) - - const FormGroup = withFieldGroup({ - defaultValues: defaultValues.person, - render: function Render({ group }) { - return - }, - }) - - const CorrectComponent1 = - const CorrectComponent2 = - }) - - it('should allow nesting withFieldGroup in other withFieldGroups', () => { - type Nested = { - firstName: string - } - type Wrapper = { - field: Nested - } - type FormValues = { - form: Wrapper - unrelated: { something: { lastName: string } } - } - - const defaultValues: FormValues = { - form: { - field: { - firstName: 'Test', - }, - }, - unrelated: { - something: { - lastName: '', - }, - }, - } - - const form = useAppForm({ - defaultValues, - }) - const LensNested = withFieldGroup({ - defaultValues: defaultValues.form.field, - render: function Render() { - return <> - }, - }) - const LensWrapper = withFieldGroup({ - defaultValues: defaultValues.form, - render: function Render({ group }) { - return ( -
- -
- ) - }, - }) - - const Component = - }) - - it('should not allow withFieldGroups with different metas to be nested', () => { - type Nested = { - firstName: string - } - type Wrapper = { - field: Nested - } - type FormValues = { - form: Wrapper - unrelated: { something: { lastName: string } } - } - - const defaultValues: FormValues = { - form: { - field: { - firstName: 'Test', - }, - }, - unrelated: { - something: { - lastName: '', - }, - }, - } - - const LensNestedNoMeta = withFieldGroup({ - defaultValues: defaultValues.form.field, - render: function Render() { - return <> - }, - }) - const LensNestedWithMeta = withFieldGroup({ - defaultValues: defaultValues.form.field, - onSubmitMeta: { meta: '' }, - render: function Render() { - return <> - }, - }) - const LensWrapper = withFieldGroup({ - defaultValues: defaultValues.form, - render: function Render({ group }) { - return ( -
- - -
- ) - }, - }) - - it('should allow mapping withFieldGroup to different fields', () => { - const defaultValues = { - firstName: '', - lastName: '', - age: 0, - relatives: [{ firstName: '', lastName: '', age: 0 }], - } - const defaultFields = { - first: '', - last: '', - } - - const form = useAppForm({ - defaultValues, - }) - - const FieldGroup = withFieldGroup({ - defaultValues: defaultFields, - render: function Render() { - return <> - }, - }) - - const Component1 = ( - - ) - - const Component2 = ( - - ) - }) - - it('should not allow fields mapping if the top level is an array', () => { - const defaultValues = { - firstName: '', - lastName: '', - age: 0, - relatives: [{ firstName: '', lastName: '', age: 0 }], - relativesRecord: { - something: { firstName: '', lastName: '', age: 0 }, - } as Record, - } - const defaultFields = { - firstName: '', - lastName: '', - } - - const form = useAppForm({ - defaultValues, - }) - - const FieldGroupRecord = withFieldGroup({ - defaultValues: { anything: defaultFields } as Record< - string, - typeof defaultFields - >, - render: function Render() { - return <> - }, - }) - const FieldGroupArray = withFieldGroup({ - defaultValues: [defaultFields], - render: function Render() { - return <> - }, - }) - - const CorrectComponent1 = ( - - ) - const WrongComponent1 = ( - - ) - const CorrectComponent3 = ( - - ) - const WrongComponent2 = ( - - ) - }) - }) - - it('should allow mapping field groups to optional fields', () => { - const groupFields = { - name: '', - } - - type WrapperValues = { - namespace: { name: string } | undefined - namespace2: { name: string } | null - namespace3: { name: string } | null | undefined - nope: null | undefined - nope2: { lastName: string } | null | undefined - } - - const defaultValues: WrapperValues = { - namespace: undefined, - namespace2: null, - namespace3: null, - nope: null, - nope2: null, - } - - const FieldGroup = withFieldGroup({ - defaultValues: groupFields, - render: function Render() { - return <> - }, - }) - - const form = useAppForm({ - defaultValues, - }) - - const Component = - const Component2 = - const Component3 = - // @ts-expect-error because it doesn't ever evaluate to the expected values - const Component4 = - // @ts-expect-error because the types don't match properly - const Component5 = - }) - - it('should allow interfaces without index signatures to be assigned to `props` in withForm and withFormGroup', () => { - interface TestNoSignature { - title: string - } - - interface TestWithSignature { - title: string - [key: string]: unknown - } - - const WithFormComponent1 = withForm({ - defaultValues: { name: '' }, - props: {} as TestNoSignature, - render: () => <>, - }) - - const WithFormComponent2 = withForm({ - defaultValues: { name: '' }, - props: {} as TestWithSignature, - render: () => <>, - }) - - const WithFieldGroupComponent1 = withFieldGroup({ - defaultValues: { name: '' }, - props: {} as TestNoSignature, - render: () => <>, - }) - - const WithFieldGroupComponent2 = withFieldGroup({ - defaultValues: { name: '' }, - props: {} as TestWithSignature, - render: () => <>, - }) - - const appForm = useAppForm({ defaultValues: { name: '' } }) - - const Component1 = - const Component2 = ( - - ) - - const FieldGroupComponent1 = ( - - ) - const FieldGroupComponent2 = ( - - ) - }) - - it('should not allow null as prop in withForm and withFormGroup', () => { - const WithFormComponent = withForm({ - defaultValues: { name: '' }, - // @ts-expect-error - props: null, - render: () => <>, - }) - }) - - const WithFieldGroupComponent = withFieldGroup({ - defaultValues: { name: '' }, - // @ts-expect-error - props: null, - render: () => <>, - }) -}) diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx deleted file mode 100644 index 7f57c3e2b..000000000 --- a/packages/react-form/tests/createFormHook.test.tsx +++ /dev/null @@ -1,583 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { render } from '@testing-library/react' -import { formOptions } from '@tanstack/form-core' -import userEvent from '@testing-library/user-event' -import { createFormHook, createFormHookContexts, useStore } from '../src' - -const user = userEvent.setup() - -const { fieldContext, useFieldContext, formContext, useFormContext } = - createFormHookContexts() - -function TextField({ label }: { label: string }) { - const field = useFieldContext() - return ( - - ) -} - -function SubscribeButton({ label }: { label: string }) { - const form = useFormContext() - return ( - state.isSubmitting}> - {(isSubmitting) => } - - ) -} - -const { useAppForm, withForm, withFieldGroup } = createFormHook({ - fieldComponents: { - TextField, - }, - formComponents: { - SubscribeButton, - }, - fieldContext, - formContext, -}) - -describe('createFormHook', () => { - it('should allow to set default value', () => { - type Person = { - firstName: string - lastName: string - } - - function Comp() { - const form = useAppForm({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - return ( - <> - } - /> - - ) - } - - const { getByLabelText } = render() - const input = getByLabelText('Testing') - expect(input).toHaveValue('FirstName') - }) - - it('should handle withForm types properly', () => { - const formOpts = formOptions({ - defaultValues: { - firstName: 'John', - lastName: 'Doe', - }, - }) - - const ChildForm = withForm({ - ...formOpts, - // Optional, but adds props to the `render` function outside of `form` - props: { - title: 'Child Form', - }, - render: ({ form, title }) => { - return ( -
-

{title}

- } - /> - - - -
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - - return - } - - const { getByLabelText, getByText } = render() - const input = getByLabelText('First Name') - expect(input).toHaveValue('John') - expect(getByText('Testing')).toBeInTheDocument() - }) - - it('should handle withFieldGroup types properly', () => { - const formOpts = formOptions({ - defaultValues: { - person: { - firstName: 'John', - lastName: 'Doe', - }, - }, - }) - - const ChildForm = withFieldGroup({ - defaultValues: formOpts.defaultValues.person, - // Optional, but adds props to the `render` function outside of `form` - props: { - title: 'Child Form', - }, - render: ({ group, title }) => { - return ( -
-

{title}

- } - /> - - - -
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - - return - } - - const { getByLabelText, getByText } = render() - const input = getByLabelText('First Name') - expect(input).toHaveValue('John') - expect(getByText('Testing')).toBeInTheDocument() - }) - - it('should use the correct field name in Field with withFieldGroup', () => { - const formOpts = formOptions({ - defaultValues: { - person: { - firstName: 'John', - lastName: 'Doe', - }, - people: [ - { - firstName: 'Jane', - lastName: 'Doe', - }, - { - firstName: 'Robert', - lastName: 'Doe', - }, - ], - }, - }) - - const ChildFormAsField = withFieldGroup({ - defaultValues: formOpts.defaultValues.person, - render: ({ group }) => { - return ( -
- } - /> - - - -
- ) - }, - }) - const ChildFormAsArray = withFieldGroup({ - defaultValues: [formOpts.defaultValues.person], - props: { - title: '', - }, - render: ({ group, title }) => { - return ( -
-

{title}

- } - /> - - - -
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - - return ( - <> - - - - - ) - } - - const { getByLabelText, getByText } = render() - const inputField1 = getByLabelText('person.firstName') - const inputArray = getByLabelText('people[0].firstName') - const inputField2 = getByLabelText('people[1].firstName') - expect(inputField1).toHaveValue('John') - expect(inputArray).toHaveValue('Jane') - expect(inputField2).toHaveValue('Robert') - expect(getByText('Testing')).toBeInTheDocument() - }) - - it('should forward Field and Subscribe to the form', () => { - const formOpts = formOptions({ - defaultValues: { - person: { - firstName: 'John', - lastName: 'Doe', - }, - }, - }) - - const ChildFormAsField = withFieldGroup({ - defaultValues: formOpts.defaultValues.person, - render: ({ group }) => { - return ( -
- ( - - )} - /> - state.values.lastName}> - {(lastName) =>

{lastName}

} -
-
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - return - } - - const { getByLabelText, getByText } = render() - const input = getByLabelText('person.firstName') - expect(input).toHaveValue('John') - expect(getByText('Doe')).toBeInTheDocument() - }) - - it('should not lose focus on update with withFieldGroup', async () => { - const formOpts = formOptions({ - defaultValues: { - person: { - firstName: 'John', - lastName: 'Doe', - }, - }, - }) - - const ChildForm = withFieldGroup({ - defaultValues: formOpts.defaultValues.person, - render: function Render({ group }) { - const firstName = useStore( - group.store, - (state) => state.values.firstName, - ) - return ( -
-

{firstName}

- ( - - )} - /> - state.values.lastName}> - {(lastName) =>

{lastName}

} -
-
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - return - } - - const { getByLabelText } = render() - - const input = getByLabelText('person.firstName') - input.focus() - expect(input).toHaveFocus() - - await user.clear(input) - await user.type(input, 'Something') - - expect(input).toHaveFocus() - }) - - it('should allow nesting withFieldGroup in other withFieldGroups', () => { - type Nested = { - firstName: string - } - type Wrapper = { - field: Nested - } - type FormValues = { - form: Wrapper - unrelated: { something: { lastName: string } } - } - - const defaultValues: FormValues = { - form: { - field: { - firstName: 'Test', - }, - }, - unrelated: { - something: { - lastName: '', - }, - }, - } - - const LensNested = withFieldGroup({ - defaultValues: defaultValues.form.field, - render: function Render({ group }) { - return ( - - {(field) =>

{field.name}

} -
- ) - }, - }) - const LensWrapper = withFieldGroup({ - defaultValues: defaultValues.form, - render: function Render({ group }) { - return ( -
- -
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - defaultValues, - }) - return - } - - const { getByText } = render() - - expect(getByText('form.field.firstName')).toBeInTheDocument() - }) - - it('should allow mapping withFieldGroup to different values', () => { - const formOpts = formOptions({ - defaultValues: { - unrelated: 'John', - values: '', - }, - }) - - const ChildFormAsField = withFieldGroup({ - defaultValues: { firstName: '', lastName: '' }, - render: ({ group }) => { - return ( -
- } - /> -
- ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - ...formOpts, - }) - - return ( - - ) - } - - const { getByLabelText } = render() - const inputField1 = getByLabelText('unrelated') - expect(inputField1).toHaveValue('John') - }) - - it('should remap FieldGroupApi.Field validators to the correct names', () => { - const FieldGroupString = withFieldGroup({ - defaultValues: { password: '', confirmPassword: '' }, - render: function Render({ group }) { - return ( - null, - onChangeListenTo: ['password'], - onBlur: () => null, - onBlurListenTo: ['confirmPassword'], - }} - > - {(field) => { - expect(field.options.validators?.onChangeListenTo).toStrictEqual([ - 'account.password', - ]) - expect(field.options.validators?.onBlurListenTo).toStrictEqual([ - 'account.confirmPassword', - ]) - return <> - }} - - ) - }, - }) - - const FieldGroupObject = withFieldGroup({ - defaultValues: { password: '', confirmPassword: '' }, - render: function Render({ group }) { - return ( - null, - onChangeListenTo: ['password'], - onBlur: () => null, - onBlurListenTo: ['confirmPassword'], - }} - > - {(field) => { - expect(field.options.validators?.onChangeListenTo).toStrictEqual([ - 'userPassword', - ]) - expect(field.options.validators?.onBlurListenTo).toStrictEqual([ - 'userConfirmPassword', - ]) - return <> - }} - - ) - }, - }) - - const Parent = () => { - const form = useAppForm({ - defaultValues: { - account: { - password: '', - confirmPassword: '', - }, - userPassword: '', - userConfirmPassword: '', - }, - }) - - return ( - <> - - - - ) - } - - render() - }) - - it('should accept formId and return it', async () => { - function Submit() { - const form = useFormContext() - - return ( - - ) - } - - function Comp() { - const form = useAppForm({ - formId: 'test', - }) - - return ( - -
{ - e.preventDefault() - form.handleSubmit() - }} - >
- - state.submissionAttempts} - children={(submissionAttempts) => ( - {submissionAttempts} - )} - /> - - -
- ) - } - - const { getByTestId } = render() - const target = getByTestId('formId-target') - const result = getByTestId('formId-result') - - await user.click(target) - expect(result).toHaveTextContent('1') - }) -}) diff --git a/packages/react-form/tests/test-setup.ts b/packages/react-form/tests/test-setup.ts deleted file mode 100644 index 33e431061..000000000 --- a/packages/react-form/tests/test-setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@testing-library/jest-dom/vitest' -import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' - -// https://testing-library.com/docs/react-testing-library/api#cleanup -afterEach(() => cleanup()) diff --git a/packages/react-form/tests/useField.test-d.tsx b/packages/react-form/tests/useField.test-d.tsx deleted file mode 100644 index a394e80df..000000000 --- a/packages/react-form/tests/useField.test-d.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { expectTypeOf, it } from 'vitest' -import { useForm } from '../src/index' - -it('should type state.value properly', () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'test', - age: 84, - }, - } as const) - - return ( - <> - { - expectTypeOf(field.state.value).toEqualTypeOf<'test'>() - return null - }} - /> - { - expectTypeOf(field.state.value).toEqualTypeOf<84>() - return null - }} - /> - - ) - } -}) - -it('should type onChange properly', () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'test', - age: 84, - }, - } as const) - - return ( - <> - { - expectTypeOf(value).toEqualTypeOf<'test'>() - return null - }, - }} - children={() => null} - /> - { - expectTypeOf(value).toEqualTypeOf<84>() - return null - }, - }} - children={() => null} - /> - - ) - } -}) - -it('should type array subfields', () => { - type FormDefinition = { - nested: { - people: { - name: string - age: number - }[] - } - } - - function App() { - const form = useForm({ - defaultValues: { - nested: { - people: [], - }, - } as FormDefinition, - onSubmit({ value }) { - alert(JSON.stringify(value)) - }, - }) - - return ( - - {(field) => - field.state.value.map((_, i) => ( - - {(subField) => ( - subField.handleChange(e.target.value)} - /> - )} - - )) - } - - ) - } -}) diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx deleted file mode 100644 index 861ad9df5..000000000 --- a/packages/react-form/tests/useField.test.tsx +++ /dev/null @@ -1,1377 +0,0 @@ -/* eslint-disable react-compiler/react-compiler */ -import { describe, expect, it, vi } from 'vitest' -import { render, waitFor, within } from '@testing-library/react' -import { userEvent } from '@testing-library/user-event' -import { StrictMode, useState } from 'react' -import { useStore } from '@tanstack/react-store' -import { useForm } from '../src/index' -import { sleep } from './utils' -import type { AnyFieldApi } from '../src/index' - -const user = userEvent.setup() - -describe('useField', () => { - it('should allow to set default value', () => { - type Person = { - firstName: string - lastName: string - } - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - return ( - <> - { - return ( - field.handleChange(e.target.value)} - /> - ) - }} - /> - - ) - } - - const { getByTestId } = render() - const input = getByTestId('fieldinput') - expect(input).toHaveValue('FirstName') - }) - - it('should use field default value first', () => { - type Person = { - firstName: string - lastName: string - } - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - return ( - <> - { - return ( - field.handleChange(e.target.value)} - /> - ) - }} - /> - - ) - } - - const { getByTestId } = render() - const input = getByTestId('fieldinput') - expect(input).toHaveValue('otherName') - }) - - it('should not validate on change if isTouched is false', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - (value === 'other' ? error : undefined), - }} - children={(field) => ( -
- - field.setValue(e.target.value, { - dontUpdateMeta: true, - }) - } - /> -

{field.getMeta().errors}

-
- )} - /> - - ) - } - - const { getByTestId, queryByText } = render() - const input = getByTestId('fieldinput') - await user.type(input, 'other') - expect(queryByText(error)).not.toBeInTheDocument() - }) - - it('should validate on change if isTouched is true', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - (value === 'other' ? error : undefined), - }} - children={(field) => ( -
- field.handleChange(e.target.value)} - /> -

{field.getMeta().errorMap.onChange}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(error)).not.toBeInTheDocument() - await user.type(input, 'other') - expect(getByText(error)).toBeInTheDocument() - }) - - it('should validate on change and on blur', async () => { - type Person = { - firstName: string - lastName: string - } - const onChangeError = 'Please enter a different value (onChangeError)' - const onBlurError = 'Please enter a different value (onBlurError)' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - - value === 'other' ? onChangeError : undefined, - onBlur: ({ value }) => - value === 'other' ? onBlurError : undefined, - }} - children={(field) => ( -
- field.handleChange(e.target.value)} - /> -

{field.getMeta().errorMap.onChange}

-

{field.getMeta().errorMap.onBlur}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(onChangeError)).not.toBeInTheDocument() - expect(queryByText(onBlurError)).not.toBeInTheDocument() - await user.type(input, 'other') - expect(getByText(onChangeError)).toBeInTheDocument() - await user.click(document.body) - expect(queryByText(onBlurError)).toBeInTheDocument() - }) - - it('should properly update conditionally rendered fields', async () => { - type FormValues = { - firstField: string - secondField: string - showFirstField: boolean - } - - function Comp() { - const form = useForm({ - defaultValues: { - firstField: '', - secondField: '', - showFirstField: true, - } as FormValues, - }) - - return ( - <> - - {({ handleChange, state }) => ( -
- Show first field - { - handleChange(e.target.checked) - }} - /> -
- )} -
- state.values.showFirstField}> - {(someFlagChecked) => { - if (someFlagChecked) { - return ( - - {({ handleChange, state }) => ( - - )} - - ) - } - - return ( - - {({ handleChange, state }) => ( - - )} - - ) - }} - - - ) - } - - const { getByTestId } = render() - - const showFirstFieldInput = getByTestId('show-first-field') - - await user.type(getByTestId('first-field'), 'hello') - expect((getByTestId('first-field') as HTMLInputElement).value).toBe('hello') - - await user.click(showFirstFieldInput) - await user.type(getByTestId('second-field'), 'world') - expect((getByTestId('second-field') as HTMLInputElement).value).toBe( - 'world', - ) - - await user.click(showFirstFieldInput) - expect((getByTestId('first-field') as HTMLInputElement).value).toBe('hello') - }) - - it('should validate async on change', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - { - await sleep(10) - return error - }, - }} - children={(field) => ( -
- field.handleChange(e.target.value)} - /> -

{field.getMeta().errorMap.onChange}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(error)).not.toBeInTheDocument() - await user.type(input, 'other') - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it('should validate async on change and async on blur', async () => { - type Person = { - firstName: string - lastName: string - } - const onChangeError = 'Please enter a different value (onChangeError)' - const onBlurError = 'Please enter a different value (onBlurError)' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - { - await sleep(10) - return onChangeError - }, - onBlurAsync: async () => { - await sleep(10) - return onBlurError - }, - }} - children={(field) => ( -
- field.handleChange(e.target.value)} - /> -

{field.getMeta().errorMap.onChange}

-

{field.getMeta().errorMap.onBlur}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - - expect(queryByText(onChangeError)).not.toBeInTheDocument() - expect(queryByText(onBlurError)).not.toBeInTheDocument() - await user.type(input, 'other') - await waitFor(() => getByText(onChangeError)) - expect(getByText(onChangeError)).toBeInTheDocument() - await user.click(document.body) - await waitFor(() => getByText(onBlurError)) - expect(getByText(onBlurError)).toBeInTheDocument() - }) - - it('should validate async on change with debounce', async () => { - type Person = { - firstName: string - lastName: string - } - const mockFn = vi.fn() - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - }) - - return ( - <> - { - mockFn() - await sleep(10) - return error - }, - }} - children={(field) => ( -
- field.handleChange(e.target.value)} - /> -

{field.getMeta().errors}

-
- )} - /> - - ) - } - - const { getByTestId, getByText } = render() - const input = getByTestId('fieldinput') - await user.type(input, 'other') - // mockFn will have been called 5 times without onChangeAsyncDebounceMs - expect(mockFn).toHaveBeenCalledTimes(0) - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it('should handle strict mode properly with conditional fields', async () => { - function FieldInfo({ field }: { field: AnyFieldApi }) { - return ( - <> - {field.state.meta.isTouched && field.state.meta.errors.length ? ( - {field.state.meta.errors.join(',')} - ) : null} - {field.state.meta.isValidating ? 'Validating...' : null} - - ) - } - - function Comp() { - const [showField, setShowField] = useState(true) - - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - onSubmit: async () => {}, - }) - - return ( -
-
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > -
- {/* A type-safe field component*/} - {showField ? ( - - !value ? 'A first name is required' : undefined, - }} - children={(field) => { - // Avoid hasty abstractions. Render props are great! - return ( - <> - - field.handleChange(e.target.value)} - /> - - - ) - }} - /> - ) : null} -
-
- ( - <> - - field.handleChange(e.target.value)} - /> - - - )} - /> -
- [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} - /> - - -
- ) - } - - const { getByText, findByText, queryByText } = render( - - - , - ) - - await user.click(getByText('Submit')) - expect(await findByText('A first name is required')).toBeInTheDocument() - await user.click(getByText('Hide field')) - await user.click(getByText('Submit')) - expect(queryByText('A first name is required')).not.toBeInTheDocument() - }) - - it('should handle arrays with primitive values', async () => { - const fn = vi.fn() - function Comp() { - const form = useForm({ - defaultValues: { - people: [] as Array, - }, - onSubmit: ({ value }) => fn(value), - }) - - return ( -
-
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - - {(field) => { - return ( -
- {field.state.value.map((_, i) => { - return ( - - {(subField) => { - return ( -
- - -
- ) - }} -
- ) - })} - -
- ) - }} -
- [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} - /> - -
- ) - } - - const { getByText, findByLabelText, queryByText, findByText } = render( - , - ) - - expect(queryByText('Name for person 0')).not.toBeInTheDocument() - expect(queryByText('Name for person 1')).not.toBeInTheDocument() - await user.click(getByText('Add person')) - const input = await findByLabelText('Name for person 0') - expect(input).toBeInTheDocument() - await user.type(input, 'John') - - await user.click(getByText('Add person')) - const input2 = await findByLabelText('Name for person 1') - expect(input).toBeInTheDocument() - await user.type(input2, 'Jack') - - expect(queryByText('Name for person 0')).toBeInTheDocument() - expect(queryByText('Name for person 1')).toBeInTheDocument() - await user.click(getByText('Remove person 1')) - expect(queryByText('Name for person 0')).toBeInTheDocument() - expect(queryByText('Name for person 1')).not.toBeInTheDocument() - - await user.click(await findByText('Submit')) - expect(fn).toHaveBeenCalledWith({ people: ['John'] }) - }) - - it('should handle arrays with subvalues', async () => { - const fn = vi.fn() - function Comp() { - const form = useForm({ - defaultValues: { - people: [] as Array<{ age: number; name: string }>, - }, - onSubmit: ({ value }) => fn(value), - }) - - return ( -
-
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - - {(field) => { - return ( -
- {field.state.value.map((_, i) => { - return ( - - {(subField) => { - return ( -
- - -
- ) - }} -
- ) - })} - -
- ) - }} -
- [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} - /> - -
- ) - } - - const { getByText, findByLabelText, queryByText, findByText } = render( - , - ) - - expect(queryByText('Name for person 0')).not.toBeInTheDocument() - expect(queryByText('Name for person 1')).not.toBeInTheDocument() - await user.click(getByText('Add person')) - const input = await findByLabelText('Name for person 0') - expect(input).toBeInTheDocument() - await user.type(input, 'John') - - await user.click(getByText('Add person')) - const input2 = await findByLabelText('Name for person 1') - expect(input).toBeInTheDocument() - await user.type(input2, 'Jack') - - expect(queryByText('Name for person 0')).toBeInTheDocument() - expect(queryByText('Name for person 1')).toBeInTheDocument() - await user.click(getByText('Remove person 1')) - expect(queryByText('Name for person 0')).toBeInTheDocument() - expect(queryByText('Name for person 1')).not.toBeInTheDocument() - - await user.click(await findByText('Submit')) - expect(fn).toHaveBeenCalledWith({ people: [{ name: 'John', age: 0 }] }) - }) - - it('should handle sync linked fields', async () => { - const fn = vi.fn() - function Comp() { - const form = useForm({ - defaultValues: { - password: '', - confirm_password: '', - }, - onSubmit: ({ value }) => fn(value), - }) - - return ( -
- - {(field) => { - return ( -
- -
- ) - }} -
- { - if (value !== fieldApi.form.getFieldValue('password')) { - return 'Passwords do not match' - } - return undefined - }, - }} - > - {(field) => { - return ( -
- - {field.state.meta.errors.map((err) => { - return
{err}
- })} -
- ) - }} -
-
- ) - } - - const { findByLabelText, queryByText, findByText } = render() - - const passwordInput = await findByLabelText('Password') - const confirmPasswordInput = await findByLabelText('Confirm Password') - await user.type(passwordInput, 'password') - await user.type(confirmPasswordInput, 'password') - expect(queryByText('Passwords do not match')).not.toBeInTheDocument() - await user.type(confirmPasswordInput, '1') - expect(await findByText('Passwords do not match')).toBeInTheDocument() - }) - - it('should handle deeply nested values in StrictMode', async () => { - function Comp() { - const form = useForm({ - defaultValues: { - name: { first: 'Test', last: 'User' }, - }, - }) - - return ( -

{field.state.value}

} - /> - ) - } - - const { queryByText, findByText } = render( - - - , - ) - - expect(queryByText('Test')).not.toBeInTheDocument() - expect(await findByText('User')).toBeInTheDocument() - }) - - it('should validate async on submit without debounce', async () => { - type Person = { - firstName: string - lastName: string - } - const mockFn = vi.fn() - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChangeAsyncDebounceMs: 1000000, - onChangeAsync: async () => { - mockFn() - await sleep(10) - return error - }, - }, - }) - const errors = useStore(form.store, (s) => s.errors) - - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors}

-
- )} - /> - - - ) - } - - const { getByRole, getByText } = render() - await user.click(getByRole('button', { name: 'Submit' })) - - expect(mockFn).toHaveBeenCalledTimes(1) - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it('should validate allow pushvalue to implicitly set a default value', async () => { - type Person = { - people: Array - } - - function Comp() { - const form = useForm({ - defaultValues: { - people: [], - } as Person, - }) - - return ( - - {(field) => { - return ( -
-
{JSON.stringify(field.state.value)}
- {field.state.value.map((_, i) => { - return ( - - {(subField) => { - return ( -
- -
- ) - }} -
- ) - })} - -
- ) - }} -
- ) - } - - const { getByText, queryByText } = render( - - - , - ) - expect(getByText('[]')).toBeInTheDocument() - await user.click(getByText('Add person')) - expect(getByText(`[""]`)).toBeInTheDocument() - }) - - it('should validate allow pushvalue to implicitly set a pushed default value', async () => { - type Person = { - people: Array - } - - function Comp() { - const form = useForm({ - defaultValues: { - people: [], - } as Person, - }) - - return ( - - {(field) => { - return ( -
-
{JSON.stringify(field.state.value)}
- {field.state.value.map((_, i) => { - return ( - - {(subField) => { - return ( -
- -
- ) - }} -
- ) - })} - -
- ) - }} -
- ) - } - - const { getByText, queryByText } = render( - - - , - ) - expect(getByText('[]')).toBeInTheDocument() - await user.click(getByText('Add person')) - expect(getByText(`["Test"]`)).toBeInTheDocument() - }) - - it('should handle removing element from array', async () => { - type Person = { - name: string - id: number - } - - const fakePeople = { - jack: { - id: 5, - name: 'Jack', - }, - molly: { - id: 6, - name: 'Molly', - }, - george: { - id: 7, - name: 'George', - }, - } satisfies Record - - function Comp() { - const form = useForm({ - defaultValues: { - people: [fakePeople.jack, fakePeople.molly, fakePeople.george], - }, - }) - - return ( - - {(field) => { - return ( -
-
- {field.state.value.map((item, i) => { - return ( - - {(subField) => { - return ( -
- -
- ) - }} -
- ) - })} -
- -
- ) - }} -
- ) - } - - const { getByText, queryByText, getByTestId } = render( - - - , - ) - - let exisingPeople: Person[] = [ - fakePeople.jack, - fakePeople.molly, - fakePeople.george, - ] - exisingPeople.forEach((person) => - expect(getByText(person.name)).toBeInTheDocument(), - ) - const container = getByTestId('container') - expect(within(container).getAllByRole('textbox')).toHaveLength(3) - - await user.click(getByText('Remove person')) - - expect(within(container).getAllByRole('textbox')).toHaveLength(2) - exisingPeople = [fakePeople.jack, fakePeople.george] - exisingPeople.forEach((person) => - expect(getByText(person.name)).toBeInTheDocument(), - ) - expect(queryByText(fakePeople.molly.name)).not.toBeInTheDocument() - }) - - it('should not rerender unrelated fields', async () => { - const renderCount = { - field1: 0, - field2: 0, - } - - function Comp() { - const form = useForm({ - defaultValues: { - field1: '', - field2: '', - }, - }) - - return ( - <> - - {(field) => { - renderCount.field1++ - return ( - field.handleChange(e.target.value)} - /> - ) - }} - - - {(field) => { - renderCount.field2++ - return ( - field.handleChange(e.target.value)} - /> - ) - }} - - - ) - } - - const { getByTestId } = render( - - - , - ) - - const field1InitialRender = renderCount.field1 - const field2InitialRender = renderCount.field2 - - await user.type(getByTestId('field1'), 'test') - - // field1 should have rerendered - expect(renderCount.field1).toBeGreaterThan(field1InitialRender) - // field2 should not have rerendered - expect(renderCount.field2).toBe(field2InitialRender) - }) - - it('should not rerender array field when child field value changes', async () => { - // Test for https://github.com/TanStack/form/issues/1925 - // Array fields should only re-render when the array length changes, - // not when a property on an array element is mutated - const renderCount = { - arrayField: 0, - childField: 0, - } - - function Comp() { - const form = useForm({ - defaultValues: { - people: [{ name: 'John' }, { name: 'Jane' }], - }, - }) - - return ( - - {(arrayField) => { - renderCount.arrayField++ - return ( -
- {arrayField.state.value.map((_, i) => ( - - {(field) => { - if (i === 0) renderCount.childField++ - return ( - field.handleChange(e.target.value)} - /> - ) - }} - - ))} - -
- ) - }} -
- ) - } - - const { getByTestId } = render( - - - , - ) - - const arrayFieldInitialRender = renderCount.arrayField - const childFieldInitialRender = renderCount.childField - - // Type into the first child field - await user.type(getByTestId('person-0'), 'ny') - - // Child field should have rerendered - expect(renderCount.childField).toBeGreaterThan(childFieldInitialRender) - // Array field should NOT have rerendered (this was the bug in #1925) - expect(renderCount.arrayField).toBe(arrayFieldInitialRender) - - // Verify typing still works - expect(getByTestId('person-0')).toHaveValue('Johnny') - - // Now add a new item - this SHOULD trigger array field re-render - const arrayFieldBeforeAdd = renderCount.arrayField - await user.click(getByTestId('add-person')) - - // Array field should have rerendered when length changes - expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd) - }) - - it('should handle defaultValue without setstate-in-render error', async () => { - // Spy on console.error before rendering - const consoleErrorSpy = vi.spyOn(console, 'error') - - function Comp() { - const form = useForm({ - defaultValues: { - fieldOne: '', - fieldTwo: '', - }, - }) - - const fieldOne = useStore(form.store, (state) => state.values.fieldOne) - - return ( -
- { - return ( - field.handleChange(e.target.value)} - /> - ) - }} - /> - {fieldOne && ( - null} - /> - )} - - ) - } - - const { getByTestId } = render() - await user.type(getByTestId('fieldOne'), 'John') - - // Should not log an error - expect(consoleErrorSpy).not.toHaveBeenCalled() - }) - - it('should allow field-level defaultValue', async () => { - function Comp() { - const form = useForm({ - defaultValues: { - name: undefined as string | undefined, - }, - }) - - return ( - - {(field) => { - expect(field.state.value).toEqual('a') - return {field.state.value} - }} - - ) - } - - const { queryByText } = render() - - expect(queryByText('a')).toBeInTheDocument() - expect(queryByText('never')).not.toBeInTheDocument() - }) -}) diff --git a/packages/react-form/tests/useForm.test-d.tsx b/packages/react-form/tests/useForm.test-d.tsx deleted file mode 100644 index 2e92cf29c..000000000 --- a/packages/react-form/tests/useForm.test-d.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { formOptions, useForm } from '../src/index' -import type { FormAsyncValidateOrFn, FormValidateOrFn } from '../src/index' -import type { ReactFormExtendedApi } from '../src/useForm' - -describe('useForm', () => { - it('should type onSubmit properly', () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'test', - age: 84, - // as const is required here - } as const, - onSubmit({ value }) { - expectTypeOf(value.age).toEqualTypeOf<84>() - }, - }) - } - }) - - it('should type a validator properly', () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'test', - age: 84, - // as const is required here - } as const, - validators: { - onChange({ value }) { - expectTypeOf(value.age).toEqualTypeOf<84>() - return undefined - }, - }, - }) - } - }) - - it('should not have recursion problems and type register properly', () => { - const register = < - TFormData, - TOnMount extends undefined | FormValidateOrFn, - TOnChange extends undefined | FormValidateOrFn, - TOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TOnBlur extends undefined | FormValidateOrFn, - TOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TOnSubmit extends undefined | FormValidateOrFn, - TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TOnDynamic extends undefined | FormValidateOrFn, - TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, - TOnServer extends undefined | FormAsyncValidateOrFn, - TSubmitMeta, - >( - f: ReactFormExtendedApi< - TFormData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TOnServer, - TSubmitMeta - >, - ) => f - - function Comp() { - const form = useForm({ - defaultValues: { - name: '', - title: '', - }, - }) - - const x = register(form) - - return null - } - }) - - it('types should be properly inferred when using formOptions', () => { - type Person = { - firstName: string - lastName: string - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const form = useForm(formOpts) - - expectTypeOf(form.state.values).toEqualTypeOf() - }) - - it('types should be properly inferred when passing args alongside formOptions', () => { - type Person = { - firstName: string - lastName: string - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const form = useForm({ - ...formOpts, - onSubmitMeta: { - test: 'test', - }, - }) - - expectTypeOf(form.handleSubmit).toEqualTypeOf<{ - (): Promise - (submitMeta: { test: string }): Promise - }>() - }) - - it('types should be properly inferred when formOptions are being overridden', () => { - type Person = { - firstName: string - lastName: string - } - - type PersonWithAge = Person & { - age: number - } - - const formOpts = formOptions({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - const form = useForm({ - ...formOpts, - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - age: 10, - }, - }) - - expectTypeOf(form.state.values).toExtend() - }) -}) diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx deleted file mode 100644 index dbb7ff845..000000000 --- a/packages/react-form/tests/useForm.test.tsx +++ /dev/null @@ -1,1006 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { render, waitFor } from '@testing-library/react' -import { userEvent } from '@testing-library/user-event' -import { useStore } from '@tanstack/react-store' -import { useEffect, useState } from 'react' -import { useForm } from '../src/index' -import { sleep } from './utils' - -const user = userEvent.setup() - -describe('useForm', () => { - it('preserves field state', async () => { - type Person = { - firstName: string - lastName: string - } - - function Comp() { - const form = useForm({ - defaultValues: {} as Person, - }) - - return ( - <> - { - return ( - field.handleChange(e.target.value)} - /> - ) - }} - /> - - ) - } - - const { getByTestId, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText('FirstName')).not.toBeInTheDocument() - await user.type(input, 'FirstName') - expect(input).toHaveValue('FirstName') - }) - - it('should allow default values to be set', async () => { - type Person = { - firstName: string - lastName: string - } - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: 'FirstName', - lastName: 'LastName', - } as Person, - }) - - return ( - <> - { - return

{field.state.value}

- }} - /> - - ) - } - - const { findByText, queryByText } = render() - expect(await findByText('FirstName')).toBeInTheDocument() - expect(queryByText('LastName')).not.toBeInTheDocument() - }) - - it('should handle submitting properly', async () => { - function Comp() { - const [submittedData, setSubmittedData] = useState<{ - firstName: string - } | null>(null) - - const form = useForm({ - defaultValues: { - firstName: 'FirstName', - }, - onSubmit: ({ value }) => { - setSubmittedData(value) - }, - }) - - return ( - <> - { - return ( - field.handleChange(e.target.value)} - placeholder={'First name'} - /> - ) - }} - /> - - {submittedData &&

Submitted data: {submittedData.firstName}

} - - ) - } - - const { findByPlaceholderText, getByText } = render() - const input = await findByPlaceholderText('First name') - await user.clear(input) - await user.type(input, 'OtherName') - await user.click(getByText('Submit')) - await waitFor(() => - expect(getByText('Submitted data: OtherName')).toBeInTheDocument(), - ) - }) - - it('should run on form mount', async () => { - function Comp() { - const [formMounted, setFormMounted] = useState(false) - const [mountForm, setMountForm] = useState(false) - - const form = useForm({ - defaultValues: { - firstName: 'FirstName', - }, - validators: { - onMount: () => { - setFormMounted(true) - return undefined - }, - }, - }) - - return ( - <> - {mountForm ? ( - <> -

{formMounted ? 'Form mounted' : 'Not mounted'}

- - ) : ( - - )} - - ) - } - - const { getByText } = render() - await user.click(getByText('Mount form')) - await waitFor(() => expect(getByText('Form mounted')).toBeInTheDocument()) - }) - - it('should validate async on change for the form', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChange() { - return error - }, - }, - }) - const onChangeError = useStore(form.store, (s) => s.errorMap.onChange) - return ( - <> - ( - field.handleChange(e.target.value)} - /> - )} - /> -

{onChangeError?.toString()}

- - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(error)).not.toBeInTheDocument() - await user.type(input, 'other') - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it('should not validate on change if isTouched is false', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChange: ({ value }) => - value.firstName === 'other' ? error : undefined, - }, - }) - - const errors = useStore(form.store, (s) => s.errors) - return ( - <> - ( -
- - field.setValue(e.target.value, { - dontUpdateMeta: true, - }) - } - /> -

{errors}

-
- )} - /> - - ) - } - - const { getByTestId, queryByText } = render() - const input = getByTestId('fieldinput') - await user.type(input, 'other') - expect(queryByText(error)).not.toBeInTheDocument() - }) - - it('should validate on change if isTouched is true', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChange: ({ value }) => - value.firstName === 'other' ? error : undefined, - }, - }) - const errors = useStore(form.store, (s) => s.errorMap) - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors.onChange?.toString()}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(error)).not.toBeInTheDocument() - await user.type(input, 'other') - expect(getByText(error)).toBeInTheDocument() - }) - - it('should validate on change and on blur', async () => { - const onChangeError = 'Please enter a different value (onChangeError)' - const onBlurError = 'Please enter a different value (onBlurError)' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - }, - validators: { - onChange: ({ value }) => { - if (value.firstName === 'other') return onChangeError - return undefined - }, - onBlur: ({ value }) => { - if (value.firstName === 'other') return onBlurError - return undefined - }, - }, - }) - - const errors = useStore(form.store, (s) => s.errorMap) - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors.onChange?.toString()}

-

{errors.onBlur?.toString()}

-
- )} - /> - - ) - } - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(onChangeError)).not.toBeInTheDocument() - expect(queryByText(onBlurError)).not.toBeInTheDocument() - await user.type(input, 'other') - expect(getByText(onChangeError)).toBeInTheDocument() - await user.click(document.body) - expect(queryByText(onBlurError)).toBeInTheDocument() - }) - - it("should set field errors from the field's validators", async () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - }, - validators: { - onChange: ({ value }) => { - if (value.firstName === 'other') - return { - form: 'Something went wrong', - fields: { - firstName: 'Please enter a different value (onChangeError)', - }, - } - return undefined - }, - onBlur: ({ value }) => { - if (value.firstName === 'other') - return 'Please enter a different value (onBlurError)' - return undefined - }, - }, - }) - - const errors = useStore(form.store, (s) => s.errorMap) - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

- {field.state.meta.errorMap.onChange} -

-
- )} - /> -

{errors.onChange?.toString()}

-

{errors.onBlur?.toString()}

- - ) - } - const { getByTestId } = render() - - expect(getByTestId('form-onchange')).toHaveTextContent('') - - const input = getByTestId('fieldinput') - - await user.type(input, 'other') - expect(getByTestId('form-onchange')).toHaveTextContent( - 'Something went wrong', - ) - expect(getByTestId('field-onchange')).toHaveTextContent( - 'Please enter a different value (onChangeError)', - ) - - await user.click(document.body) - expect(getByTestId('form-onblur')).toHaveTextContent( - 'Please enter a different value (onBlurError)', - ) - }) - - it('should validate async on change', async () => { - type Person = { - firstName: string - lastName: string - } - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChangeAsync: async () => { - await sleep(10) - return error - }, - }, - }) - const errors = useStore(form.store, (s) => s.errorMap) - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors.onChange?.toString()}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - expect(queryByText(error)).not.toBeInTheDocument() - await user.type(input, 'other') - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it("should set field errors from the the form's onChangeAsync validator", async () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - validators: { - onChangeAsync: async ({ value }) => { - await sleep(10) - if (value.firstName === 'other') { - return { - form: 'Invalid form values', - fields: { - firstName: 'First name cannot be "other"', - }, - } - } - return null - }, - }, - }) - - const errors = useStore(form.store, (s) => s.errorMap) - - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

- {field.state.meta.errorMap.onChange} -

-
- )} - /> -

{errors.onChange?.toString()}

- - ) - } - - const { getByTestId } = render() - const input = getByTestId('fieldinput') - const firstNameErrorElement = getByTestId('field-error') - const formErrorElement = getByTestId('form-error') - - expect(firstNameErrorElement).toBeEmptyDOMElement() - expect(formErrorElement).toBeEmptyDOMElement() - - await user.type(input, 'other') - - await waitFor(() => { - expect(firstNameErrorElement).not.toBeEmptyDOMElement() - }) - expect(firstNameErrorElement).toHaveTextContent( - 'First name cannot be "other"', - ) - expect(formErrorElement).toHaveTextContent('Invalid form values') - }) - - it('should validate async on change and async on blur', async () => { - type Person = { - firstName: string - lastName: string - } - const onChangeError = 'Please enter a different value (onChangeError)' - const onBlurError = 'Please enter a different value (onBlurError)' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChangeAsync: async () => { - await sleep(10) - return onChangeError - }, - onBlurAsync: async () => { - await sleep(10) - return onBlurError - }, - }, - }) - const errors = useStore(form.store, (s) => s.errorMap) - - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors.onChange?.toString()}

-

{errors.onBlur?.toString()}

-
- )} - /> - - ) - } - - const { getByTestId, getByText, queryByText } = render() - const input = getByTestId('fieldinput') - - expect(queryByText(onChangeError)).not.toBeInTheDocument() - expect(queryByText(onBlurError)).not.toBeInTheDocument() - await user.type(input, 'other') - await waitFor(() => getByText(onChangeError)) - expect(getByText(onChangeError)).toBeInTheDocument() - await user.click(document.body) - await waitFor(() => getByText(onBlurError)) - expect(getByText(onBlurError)).toBeInTheDocument() - }) - - it('should validate async on change with debounce', async () => { - type Person = { - firstName: string - lastName: string - } - const mockFn = vi.fn() - const error = 'Please enter a different value' - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - } as Person, - validators: { - onChangeAsyncDebounceMs: 100, - onChangeAsync: async () => { - mockFn() - await sleep(10) - return error - }, - }, - }) - const errors = useStore(form.store, (s) => s.errors) - - return ( - <> - ( -
- field.handleChange(e.target.value)} - /> -

{errors}

-
- )} - /> - - ) - } - - const { getByTestId, getByText } = render() - const input = getByTestId('fieldinput') - await user.type(input, 'other') - // mockFn will have been called 5 times without onChangeAsyncDebounceMs - expect(mockFn).toHaveBeenCalledTimes(0) - await waitFor(() => getByText(error)) - expect(getByText(error)).toBeInTheDocument() - }) - - it("should set errors on the fields from the form's onChange validator", async () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - validators: { - onChange: ({ value }) => { - if (value.firstName === 'Tom') { - return { - form: 'Something went wrong', - fields: { firstName: 'Please enter a different value' }, - } - } - return null - }, - }, - }) - - const onChangeError = useStore(form.store, (s) => s.errorMap.onChange) - - return ( - <> - ( - <> - field.handleChange(e.target.value)} - /> - -

- {field.state.meta.errors.join('')} -

- - )} - /> -

{onChangeError?.toString()}

- - ) - } - - const { getByTestId, queryByText } = render() - const input = getByTestId('fieldinput') - const fieldError = getByTestId('field-error') - const formError = getByTestId('form-error') - - expect( - queryByText('Please enter a different value'), - ).not.toBeInTheDocument() - expect(queryByText('Something went wrong')).not.toBeInTheDocument() - - await user.type(input, 'Tom') - await waitFor(() => - expect(fieldError.textContent).toBe('Please enter a different value'), - ) - await waitFor(() => - expect(formError.textContent).toBe('Something went wrong'), - ) - }) - - it('should not cause infinite re-renders when listening to state.errors', () => { - const fn = vi.fn() - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - onSubmit: async ({ value }) => { - // Do something with form data - console.log(value) - }, - }) - - const { errors } = useStore(form.store, (state) => ({ - errors: state.errors, - })) - - useEffect(() => { - fn(errors) - }, [errors]) - - return null - } - - render() - - expect(fn).toHaveBeenCalledTimes(1) - }) - - it('should not cause infinite re-renders when listening to state', () => { - const fn = vi.fn() - - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - onSubmit: async ({ value }) => { - // Do something with form data - console.log(value) - }, - }) - - const { values } = useStore(form.store) - - useEffect(() => { - fn(values) - }, [values]) - - return null - } - - render() - - expect(fn).toHaveBeenCalledTimes(1) - }) - - it('form should reset default value when resetting in onSubmit', async () => { - function Comp() { - const form = useForm({ - defaultValues: { - name: '', - }, - onSubmit: ({ value }) => { - expect(value).toEqual({ name: 'another-test' }) - - form.reset(value) - }, - }) - - return ( -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - ( - field.handleChange(e.target.value)} - /> - )} - /> - - - - - - ) - } - - const { getByTestId } = render() - const input = getByTestId('fieldinput') - const submit = getByTestId('submit') - const reset = getByTestId('reset') - - await user.type(input, 'test') - await waitFor(() => expect(input).toHaveValue('test')) - - await user.click(reset) - await waitFor(() => expect(input).toHaveValue('')) - - await user.type(input, 'another-test') - await user.click(submit) - await waitFor(() => expect(input).toHaveValue('another-test')) - }) - - it('should accept formId and return it', async () => { - function Comp() { - const form = useForm({ - formId: 'test', - }) - - return ( - <> -
{ - e.preventDefault() - form.handleSubmit() - }} - >
- - state.submissionAttempts} - children={(submissionAttempts) => ( - {submissionAttempts} - )} - /> - - - - ) - } - - const { getByTestId } = render() - const target = getByTestId('formId-target') - const result = getByTestId('formId-result') - - await user.click(target) - expect(result).toHaveTextContent('1') - }) - - it('should allow custom component keys for arrays', async () => { - function Comp() { - const form = useForm({ - defaultValues: { - foo: [ - { name: 'nameA', id: 'a' }, - { name: 'nameB', id: 'b' }, - { name: 'nameC', id: 'c' }, - ], - }, - }) - - return ( - <> - - {(arrayField) => - arrayField.state.value.map((_, i) => ( - // eslint-disable-next-line @eslint-react/no-array-index-key - - {(field) => { - expect(field.name).toBe(`foo[${i}].name`) - expect(field.state.value).not.toBeUndefined() - return null - }} - - )) - } - - - - ) - } - - const { getByTestId } = render() - - const target = getByTestId('removeField') - await user.click(target) - }) - - it('should not error when using deleteField in edge cases', async () => { - function Comp() { - const form = useForm({ - defaultValues: { - firstName: '', - lastName: '', - }, - validators: { - onChange: ({ value }) => { - const fields: Record = {} - - if (value.firstName.length === 0) { - fields.firstName = 'Last Name is required' - } - - return { fields } - }, - }, - }) - - return ( -
{ - e.preventDefault() - form.handleSubmit() - }} - > -

Personal Information

- ( - field.handleChange(e.target.value)} - /> - )} - /> - ( - field.handleChange(e.target.value)} - /> - )} - /> - - - ) - } - - const { getByTestId } = render() - const removeButton = getByTestId('remove') - const input = getByTestId('input') - - await user.type(input, 'a') - await user.click(removeButton) - }) -}) diff --git a/packages/react-form/tests/useFormDelayed.test.tsx b/packages/react-form/tests/useFormDelayed.test.tsx new file mode 100644 index 000000000..fad445a17 --- /dev/null +++ b/packages/react-form/tests/useFormDelayed.test.tsx @@ -0,0 +1,161 @@ +import { render, waitFor, screen } from '@testing-library/react' +import React, { useEffect, useState } from 'react' +import { useForm } from '../src/useForm' +import { describe, it, expect, vi } from 'vitest' + +describe('useForm deferred mounting', () => { + it('should not run onMount listeners or validation until defaultValues are provided', async () => { + const onMountListener = vi.fn() + const onMountValidator = vi.fn() + + function Form() { + const [data, setData] = useState(undefined) + + const form = useForm({ + defaultValues: data, + listeners: { + onMount: onMountListener, + }, + validators: { + onMount: onMountValidator, + }, + }) + + useEffect(() => { + setTimeout(() => { + setData({ firstName: 'Test' }) + }, 100) + }, []) + + return
Form loaded
+ } + + render(
) + + // Initially, listeners should not have run + expect(onMountListener).not.toHaveBeenCalled() + expect(onMountValidator).not.toHaveBeenCalled() + + // Wait for data to populate + await waitFor(() => expect(onMountListener).toHaveBeenCalledTimes(1)) + + // Check that it ran once + expect(onMountValidator).toHaveBeenCalledTimes(1) + }) + + it('should run onMount listeners immediately if defaultValues are provided initially', async () => { + const onMountListener = vi.fn() + const onMountValidator = vi.fn() + + function Form() { + const form = useForm({ + defaultValues: { firstName: 'Test' }, + listeners: { + onMount: onMountListener, + }, + validators: { + onMount: onMountValidator, + }, + }) + + return
Form loaded
+ } + + render() + + await waitFor(() => expect(onMountListener).toHaveBeenCalledTimes(1)) + expect(onMountValidator).toHaveBeenCalledTimes(1) + }) + + it('should not run FIELD onMount listeners or validation until defaultValues are provided', async () => { + const onMountFieldListener = vi.fn() + const onMountFieldValidator = vi.fn() + + function Form() { + const [data, setData] = useState(undefined) + + const form = useForm({ + defaultValues: data, + }) + + return ( +
+ ( + field.handleChange(e.target.value)} + /> + )} + /> + +
+ ) + } + + render() + + // Initially, listeners should not have run + expect(onMountFieldListener).not.toHaveBeenCalled() + expect(onMountFieldValidator).not.toHaveBeenCalled() + + // Simulate loading data + const button = screen.getByText('Load Data') + button.click() + + // Wait for data to populate and listeners to run + await waitFor(() => expect(onMountFieldListener).toHaveBeenCalledTimes(1)) + + // Check that it ran once + expect(onMountFieldValidator).toHaveBeenCalledTimes(1) + }) + + it('should run FIELD onMount listeners immediately if defaultValues are provided initially', async () => { + const onMountFieldListener = vi.fn() + const onMountFieldValidator = vi.fn() + + function Form() { + const form = useForm({ + defaultValues: { firstName: 'Test' }, + }) + + return ( +
+ ( + field.handleChange(e.target.value)} + /> + )} + /> +
+ ) + } + + render() + + await waitFor(() => expect(onMountFieldListener).toHaveBeenCalledTimes(1)) + expect(onMountFieldValidator).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-form/tests/utils.ts b/packages/react-form/tests/utils.ts deleted file mode 100644 index 1a3a619a2..000000000 --- a/packages/react-form/tests/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function sleep(timeout: number): Promise { - return new Promise((resolve, _reject) => { - setTimeout(resolve, timeout) - }) -}