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 (
- // ...
+
+ (
+ field.handleChange(e.target.value)}
+ />
+ )}
+ />
+ (
+ field.handleChange(e.target.value)}
+ />
+ )}
+ />
+ Submit
+
+
)
}
```
-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 (
-
- {label}
- field.handleChange(e.target.value)}
- />
-
- )
-}
-
-function SubscribeButton({ label }: { label: string }) {
- const form = useFormContext()
- return (
- state.isSubmitting}>
- {(isSubmitting) => {label} }
-
- )
-}
-
-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 (
-
-
(
-
- {field.name}
- field.handleChange(e.target.value)}
- />
-
- )}
- />
- 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}
-
(
-
- {field.name}
- field.handleChange(e.target.value)}
- />
-
- )}
- />
- 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 (
-
- {form.formId}
-
- )
- }
-
- function Comp() {
- const form = useAppForm({
- formId: 'test',
- })
-
- return (
-
-
-
- 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 }) => (
-
- first field
- handleChange(e.target.value)}
- />
-
- )}
-
- )
- }
-
- return (
-
- {({ handleChange, state }) => (
-
- second field
- handleChange(e.target.value)}
- />
-
- )}
-
- )
- }}
-
- >
- )
- }
-
- 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 (
-
-
[state.canSubmit, state.isSubmitting]}
- children={([canSubmit, isSubmitting]) => (
-
- {isSubmitting ? '...' : 'Submit'}
-
- )}
- />
- setShowField((prev) => !prev)}>
- {showField ? 'Hide field' : 'Show field'}
-
-
-
- )
- }
-
- 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 (
-
-
- {(field) => {
- return (
-
- {field.state.value.map((_, i) => {
- return (
-
- {(subField) => {
- return (
-
-
- Name for person {i}
-
- subField.handleChange(e.target.value)
- }
- />
-
-
field.removeValue(i)}
- type="button"
- >
- Remove person {i}
-
-
- )
- }}
-
- )
- })}
-
field.pushValue('')} type="button">
- Add person
-
-
- )
- }}
-
-
[state.canSubmit, state.isSubmitting]}
- children={([canSubmit, isSubmitting]) => (
-
- {isSubmitting ? '...' : 'Submit'}
-
- )}
- />
-
-
- )
- }
-
- 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 (
-
-
- {(field) => {
- return (
-
- {field.state.value.map((_, i) => {
- return (
-
- {(subField) => {
- return (
-
-
- Name for person {i}
-
- subField.handleChange(e.target.value)
- }
- />
-
-
field.removeValue(i)}
- type="button"
- >
- Remove person {i}
-
-
- )
- }}
-
- )
- })}
-
field.pushValue({ name: '', age: 0 })}
- type="button"
- >
- Add person
-
-
- )
- }}
-
-
[state.canSubmit, state.isSubmitting]}
- children={([canSubmit, isSubmitting]) => (
-
- {isSubmitting ? '...' : 'Submit'}
-
- )}
- />
-
-
- )
- }
-
- 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 (
-
-
- Password
- field.handleChange(e.target.value)}
- />
-
-
- )
- }}
-
-
{
- if (value !== fieldApi.form.getFieldValue('password')) {
- return 'Passwords do not match'
- }
- return undefined
- },
- }}
- >
- {(field) => {
- return (
-
-
- Confirm Password
- field.handleChange(e.target.value)}
- />
-
- {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}
-
- )}
- />
- Submit
- >
- )
- }
-
- 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 (
-
-
- Name for person {i}
-
- subField.handleChange(e.target.value)
- }
- />
-
-
- )
- }}
-
- )
- })}
-
field.pushValue('')} type="button">
- Add person
-
-
- )
- }}
-
- )
- }
-
- 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 (
-
-
- Name for person {i}
-
- subField.handleChange(e.target.value)
- }
- />
-
-
- )
- }}
-
- )
- })}
-
field.pushValue('Test')} type="button">
- Add person
-
-
- )
- }}
-
- )
- }
-
- 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 (
-
-
- Name for person {i}
- {subField.state.value}
-
- subField.handleChange(e.target.value)
- }
- />
-
-
- )
- }}
-
- )
- })}
-
-
field.removeValue(1)} type="button">
- Remove person
-
-
- )
- }}
-
- )
- }
-
- 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)}
- />
- )
- }}
-
- ))}
- arrayField.pushValue({ name: '' })}
- >
- Add
-
-
- )
- }}
-
- )
- }
-
- 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'}
- />
- )
- }}
- />
- Submit
- {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'}
- >
- ) : (
- setMountForm(true)}>Mount form
- )}
- >
- )
- }
-
- 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 (
- (
- field.handleChange(e.target.value)}
- />
- )}
- />
-
-
- submit
-
-
- form.reset()}>
- Reset
-
-
- )
- }
-
- 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 (
- <>
-
-
- state.submissionAttempts}
- children={(submissionAttempts) => (
- {submissionAttempts}
- )}
- />
-
-
- {form.formId}
-
- >
- )
- }
-
- 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
- }}
-
- ))
- }
-
- form.removeFieldValue('foo', 1)}
- data-testid="removeField"
- >
- Remove
-
- >
- )
- }
-
- 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 (
- (
- field.handleChange(e.target.value)}
- />
- )}
- />
- (
- field.handleChange(e.target.value)}
- />
- )}
- />
- form.deleteField('firstName')}
- >
- remove first name
-
-
- )
- }
-
- 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)}
+ />
+ )}
+ />
+ setData({ firstName: 'Test' })}>
+ Load Data
+
+
+ )
+ }
+
+ 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)
- })
-}