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': major
---

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
}
```
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 @@ -2195,15 +2197,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
Loading