Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't consider type changes a breaking change, especially not ones like this that try to prevent existing runtime errors from being undetected.

See https://tanstack.com/form/latest/docs/typescript for more info.

Suggested change
'@tanstack/form-core': major
'@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 @@ -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
Loading