Skip to content
5 changes: 5 additions & 0 deletions .changeset/bumpy-boats-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

- Make `fieldMeta` record type `Partial<>` to reflect runtime behaviour
36 changes: 24 additions & 12 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ export type BaseFormState<
/**
* A record of field metadata for each field in the form, not including the derived properties, like `errors` and such
*/
fieldMetaBase: Record<DeepKeys<TFormData>, AnyFieldMetaBase>
fieldMetaBase: Partial<Record<DeepKeys<TFormData>, AnyFieldMetaBase>>
/**
* A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called.
*
Expand Down Expand Up @@ -738,7 +738,7 @@ export type DerivedFormState<
/**
* A record of field metadata for each field in the form.
*/
fieldMeta: Record<DeepKeys<TFormData>, AnyFieldMeta>
fieldMeta: Partial<Record<DeepKeys<TFormData>, AnyFieldMeta>>
}

export interface FormState<
Expand Down Expand Up @@ -929,8 +929,22 @@ export class FormApi<
TOnServer
>
>
fieldMetaDerived!: Derived<Record<DeepKeys<TFormData>, AnyFieldMeta>>
store!: Derived<
fieldMetaDerived: Derived<
FormState<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer
>['fieldMeta']
>
store: Derived<
FormState<
TFormData,
TOnMount,
Expand Down Expand Up @@ -1024,7 +1038,7 @@ export class FormApi<

let originalMetaCount = 0

const fieldMeta = {} as FormState<
const fieldMeta: FormState<
TFormData,
TOnMount,
TOnChange,
Expand All @@ -1036,7 +1050,7 @@ export class FormApi<
TOnDynamic,
TOnDynamicAsync,
TOnServer
>['fieldMeta']
>['fieldMeta'] = {}

for (const fieldName of Object.keys(
currBaseStore.fieldMetaBase,
Expand Down Expand Up @@ -1642,7 +1656,6 @@ export class FormApi<
for (const field of Object.keys(
this.state.fieldMeta,
) as DeepKeys<TFormData>[]) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.baseStore.state.fieldMetaBase[field] === undefined) {
continue
}
Expand Down Expand Up @@ -1850,7 +1863,6 @@ export class FormApi<
for (const field of Object.keys(
this.state.fieldMeta,
) as DeepKeys<TFormData>[]) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.baseStore.state.fieldMetaBase[field] === undefined) {
continue
}
Expand Down Expand Up @@ -2205,15 +2217,15 @@ export class FormApi<
* resets every field's meta
*/
resetFieldMeta = <TField extends DeepKeys<TFormData>>(
fieldMeta: Record<TField, AnyFieldMeta>,
): Record<TField, AnyFieldMeta> => {
fieldMeta: Partial<Record<TField, AnyFieldMeta>>,
): Partial<Record<TField, AnyFieldMeta>> => {
return Object.keys(fieldMeta).reduce(
(acc: Record<TField, AnyFieldMeta>, key) => {
(acc, key) => {
const fieldKey = key as TField
acc[fieldKey] = defaultFieldMeta
return acc
},
{} as Record<TField, AnyFieldMeta>,
{} as Partial<Record<TField, AnyFieldMeta>>,
)
}

Expand Down
45 changes: 37 additions & 8 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,35 @@ describe('form api', () => {
expect(form.state.values).toEqual({ name: 'initial' })
})

it('should handle multiple fields with mixed mount states', () => {
const form = new FormApi({
defaultValues: {
firstName: '',
lastName: '',
email: '',
},
})

const firstNameField = new FieldApi({
form,
name: 'firstName',
})

firstNameField.mount()

expect(form.state.fieldMeta.firstName).toBeDefined()

expect(form.state.fieldMeta.email).toBeUndefined()

const lastNameField = new FieldApi({
form,
name: 'lastName',
})
lastNameField.mount()

expect(form.state.fieldMeta.lastName).toBeDefined()
})

it("should get a field's value", () => {
const form = new FormApi({
defaultValues: {
Expand Down Expand Up @@ -1691,10 +1720,10 @@ describe('form api', () => {
await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errors).toEqual([
expect(form.state.fieldMeta['firstName']!.errors).toEqual([
'first name is required',
])
expect(form.state.fieldMeta['lastName'].errors).toEqual([
expect(form.state.fieldMeta['lastName']!.errors).toEqual([
'last name is required',
])
})
Expand Down Expand Up @@ -1730,10 +1759,10 @@ describe('form api', () => {
await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['person.firstName'].errors).toEqual([
expect(form.state.fieldMeta['person.firstName']!.errors).toEqual([
'first name is required',
])
expect(form.state.fieldMeta['person.lastName'].errors).toEqual([
expect(form.state.fieldMeta['person.lastName']!.errors).toEqual([
'last name is required',
])
})
Expand Down Expand Up @@ -1764,7 +1793,7 @@ describe('form api', () => {
await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errors).toEqual([
expect(form.state.fieldMeta['firstName']!.errors).toEqual([
'first name is required',
'first name must be longer than 3 characters',
])
Expand Down Expand Up @@ -1873,7 +1902,7 @@ describe('form api', () => {
await vi.runAllTimersAsync()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errorMap).toEqual({
expect(form.state.fieldMeta['firstName']!.errorMap).toEqual({
onChange: 'first name is required',
onBlur: 'first name must be longer than 3 characters',
})
Expand All @@ -1900,14 +1929,14 @@ describe('form api', () => {
await form.handleSubmit()
expect(form.state.isFieldsValid).toEqual(false)
expect(form.state.canSubmit).toEqual(false)
expect(form.state.fieldMeta['firstName'].errorMap['onSubmit']).toEqual(
expect(form.state.fieldMeta['firstName']!.errorMap['onSubmit']).toEqual(
'first name is required',
)
field.handleChange('test')
expect(form.state.isFieldsValid).toEqual(true)
expect(form.state.canSubmit).toEqual(true)
expect(
form.state.fieldMeta['firstName'].errorMap['onSubmit'],
form.state.fieldMeta['firstName']!.errorMap['onSubmit'],
).toBeUndefined()
})

Expand Down
186 changes: 186 additions & 0 deletions packages/form-core/tests/fieldMeta.spec.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*.spec.ts files imply they're runtime tests, while type tests go into *.test-d.ts.

  • Please rename the file to fieldMeta.spec.ts
  • Change the describe string since the file tests for meta accessing and not type safety.

Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import { FieldApi, FormApi } from '../src/index'

describe('fieldMeta accessing', () => {
it('should return undefined for unmounted fields', () => {
const form = new FormApi({
defaultValues: {
name: '',
email: '',
},
})

expect(form.state.fieldMeta.name).toBeUndefined()
expect(form.state.fieldMeta.email).toBeUndefined()
})

it('should have defined fieldMeta after field is mounted', () => {
const form = new FormApi({
defaultValues: {
name: '',
},
})

const field = new FieldApi({
form,
name: 'name',
})

field.mount()

expect(form.state.fieldMeta.name).toBeDefined()
expect(form.state.fieldMeta.name?.isValid).toBe(true)
expect(form.state.fieldMeta.name?.isTouched).toBe(false)
expect(form.state.fieldMeta.name?.isDirty).toBe(false)
})

it('should handle nested field paths', () => {
const form = new FormApi({
defaultValues: {
user: {
profile: {
firstName: '',
lastName: '',
},
},
},
})

expect(form.state.fieldMeta['user.profile.firstName']).toBeUndefined()
expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined()

const firstNameField = new FieldApi({
form,
name: 'user.profile.firstName',
})

firstNameField.mount()

expect(form.state.fieldMeta['user.profile.firstName']).toBeDefined()

expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined()
})

it('should handle array fields', () => {
const form = new FormApi({
defaultValues: {
items: ['item1', 'item2'],
},
})

expect(form.state.fieldMeta['items[0]']).toBeUndefined()
expect(form.state.fieldMeta['items[1]']).toBeUndefined()

const field0 = new FieldApi({
form,
name: 'items[0]',
})

field0.mount()

expect(form.state.fieldMeta['items[0]']).toBeDefined()
expect(form.state.fieldMeta['items[1]']).toBeUndefined()
})

it('should handle getFieldMeta returning undefined', () => {
const form = new FormApi({
defaultValues: {
name: '',
},
})

const fieldMeta = form.getFieldMeta('name')
expect(fieldMeta).toBeUndefined()

const field = new FieldApi({
form,
name: 'name',
})

field.mount()

const fieldMetaAfterMount = form.getFieldMeta('name')
expect(fieldMetaAfterMount).toBeDefined()
expect(fieldMetaAfterMount?.isValid).toBe(true)
})

it('should handle multiple fields with mixed mount states', () => {
const form = new FormApi({
defaultValues: {
firstName: '',
lastName: '',
email: '',
},
})

const firstNameField = new FieldApi({
form,
name: 'firstName',
})

const lastNameField = new FieldApi({
form,
name: 'lastName',
})

firstNameField.mount()

expect(form.state.fieldMeta.firstName).toBeDefined()
expect(form.state.fieldMeta.email).toBeUndefined()
})

it('should preserve fieldMeta after unmounting and remounting', () => {
const form = new FormApi({
defaultValues: {
name: '',
},
})

const field = new FieldApi({
form,
name: 'name',
})

const cleanup = field.mount()

field.setValue('test')
expect(form.state.fieldMeta.name?.isTouched).toBe(true)
expect(form.state.fieldMeta.name?.isDirty).toBe(true)

cleanup()

const metaAfterCleanup = form.state.fieldMeta.name

expect(metaAfterCleanup).toBeDefined()
})

it('should work with form validation that accesses fieldMeta', () => {
const form = new FormApi({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
onChange: ({ value }) => {
if (value.password !== value.confirmPassword) {
return 'Passwords must match'
}
return undefined
},
},
})

form.mount()

const passwordField = new FieldApi({
form,
name: 'password',
})

passwordField.mount()

expect(() => {
passwordField.setValue('test123')
}).not.toThrow()
})
})
Loading