Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/bumpy-boats-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@tanstack/form-core': patch
---

Make fieldMeta values optional to reflect runtime behavior and prevent crashes

BREAKING CHANGE: `fieldMeta` values are now typed as `Record<DeepKeys<TData>, AnyFieldMeta | undefined>` instead of `Record<DeepKeys<TData>, AnyFieldMeta>`. This accurately reflects that field metadata is only available after a field has been mounted.

**Why:** Previously, TypeScript allowed unchecked access to `fieldMeta` properties, leading to runtime crashes when accessing metadata of unmounted fields during the first render.

**What changed:** The type now includes `undefined` in the union, forcing developers to handle the case where a field hasn't been mounted yet.

**How to migrate:**

```typescript
// Before (crashes at runtime)
const isValid = form.state.fieldMeta.name.isValid

// After - use optional chaining
const isValid = form.state.fieldMeta.name?.isValid

// Or explicit undefined check
const fieldMeta = form.state.fieldMeta.name
if (fieldMeta) {
const isValid = fieldMeta.isValid
}
Comment on lines +5 to +26
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure how GH actions will handle descriptions like this. I assume it'll be okay? Either way, as mentioned above, types aren't considered breaking changes.

```
14 changes: 8 additions & 6 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
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: Record<DeepKeys<TFormData>, AnyFieldMeta | undefined>
}

export interface FormState<
Expand Down Expand Up @@ -929,7 +929,9 @@ export class FormApi<
TOnServer
>
>
fieldMetaDerived!: Derived<Record<DeepKeys<TFormData>, AnyFieldMeta>>
fieldMetaDerived!: Derived<
Record<DeepKeys<TFormData>, AnyFieldMeta | undefined>
>
store!: Derived<
FormState<
TFormData,
Expand Down Expand Up @@ -2205,15 +2207,15 @@ export class FormApi<
* resets every field's meta
*/
resetFieldMeta = <TField extends DeepKeys<TFormData>>(
fieldMeta: Record<TField, AnyFieldMeta>,
): Record<TField, AnyFieldMeta> => {
fieldMeta: Record<TField, AnyFieldMeta | undefined>,
): Record<TField, AnyFieldMeta | undefined> => {
return Object.keys(fieldMeta).reduce(
(acc: Record<TField, AnyFieldMeta>, key) => {
(acc: Record<TField, AnyFieldMeta | undefined>, key) => {
const fieldKey = key as TField
acc[fieldKey] = defaultFieldMeta
return acc
},
{} as Record<TField, AnyFieldMeta>,
{} as Record<TField, AnyFieldMeta | undefined>,
)
}

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