diff --git a/.changeset/bumpy-boats-roll.md b/.changeset/bumpy-boats-roll.md new file mode 100644 index 000000000..ae5b5bf16 --- /dev/null +++ b/.changeset/bumpy-boats-roll.md @@ -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, AnyFieldMeta | undefined>` instead of `Record, 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 +} +``` diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index f18a348d3..a0a5d406d 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -738,7 +738,7 @@ export type DerivedFormState< /** * A record of field metadata for each field in the form. */ - fieldMeta: Record, AnyFieldMeta> + fieldMeta: Record, AnyFieldMeta | undefined> } export interface FormState< @@ -929,7 +929,9 @@ export class FormApi< TOnServer > > - fieldMetaDerived!: Derived, AnyFieldMeta>> + fieldMetaDerived!: Derived< + Record, AnyFieldMeta | undefined> + > store!: Derived< FormState< TFormData, @@ -2205,15 +2207,15 @@ export class FormApi< * resets every field's meta */ resetFieldMeta = >( - fieldMeta: Record, - ): Record => { + fieldMeta: Record, + ): Record => { return Object.keys(fieldMeta).reduce( - (acc: Record, key) => { + (acc: Record, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, - {} as Record, + {} as Record, ) } diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 901aa6dee..b3a8e695b 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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: { @@ -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', ]) }) @@ -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', ]) }) @@ -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', ]) @@ -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', }) @@ -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() }) diff --git a/packages/form-core/tests/fieldMeta.spec.ts b/packages/form-core/tests/fieldMeta.spec.ts new file mode 100644 index 000000000..33b4b96df --- /dev/null +++ b/packages/form-core/tests/fieldMeta.spec.ts @@ -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() + }) +})