From 2e153e361c7cca59068b646e19951dfc30c05ef9 Mon Sep 17 00:00:00 2001 From: Ashish Dabral Date: Sat, 4 Oct 2025 11:26:39 +0530 Subject: [PATCH 1/3] fix(form-core): make fieldMeta values optional to reflect runtime behavior fieldMeta is typed as Record, AnyFieldMeta> but is initialized as an empty object. This causes TypeScript to incorrectly assume that accessing any valid field key will return a defined AnyFieldMeta object, leading to runtime crashes when accessing field metadata during the first render. Updated the fieldMeta type to include undefined to accurately reflect that field metadata is only available after a field has been mounted. BREAKING CHANGE: fieldMeta values are now typed as potentially undefined. Code that accesses fieldMeta without null checks will now show TypeScript errors. Use optional chaining or explicit undefined checks. Fixes #1774 --- packages/form-core/src/FormApi.ts | 12 +- packages/form-core/tests/FormApi.spec.ts | 45 +++- packages/form-core/tests/fieldMeta.test.ts | 270 +++++++++++++++++++++ 3 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 packages/form-core/tests/fieldMeta.test.ts diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 611941b19..35a7c3b05 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,7 @@ export class FormApi< TOnServer > > - fieldMetaDerived!: Derived, AnyFieldMeta>> + fieldMetaDerived!: Derived, AnyFieldMeta | undefined>> store!: Derived< FormState< TFormData, @@ -2195,15 +2195,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.test.ts b/packages/form-core/tests/fieldMeta.test.ts new file mode 100644 index 000000000..eea248540 --- /dev/null +++ b/packages/form-core/tests/fieldMeta.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest' +import { FormApi, FieldApi } from '../src/index' + +describe('fieldMeta type safety', () => { + 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 not crash when accessing properties on undefined fieldMeta with optional chaining', () => { + const form = new FormApi({ + defaultValues: { + name: '', + email: '', + }, + }) + + expect(() => { + const isValid = form.state.fieldMeta.name?.isValid + const isTouched = form.state.fieldMeta.name?.isTouched + const errors = form.state.fieldMeta.name?.errors + return { isValid, isTouched, errors } + }).not.toThrow() + + expect(form.state.fieldMeta.name?.isValid).toBeUndefined() + expect(form.state.fieldMeta.name?.isTouched).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 allow conditional access patterns', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + const isValid1 = form.state.fieldMeta.name?.isValid ?? true + expect(isValid1).toBe(true) + + const fieldMeta = form.state.fieldMeta.name + let isValid2 = true + if (fieldMeta) { + isValid2 = fieldMeta.isValid + } + expect(isValid2).toBe(true) + + const errors = form.state.fieldMeta.name?.errors ?? [] + expect(errors).toEqual([]) + }) + + 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() + }) + + it('should handle Object.values on fieldMeta safely', () => { + const form = new FormApi({ + defaultValues: { + field1: '', + field2: '', + field3: '', + }, + }) + + const field1 = new FieldApi({ + form, + name: 'field1', + }) + + field1.mount() + + const fieldMetaValues = Object.values(form.state.fieldMeta).filter(Boolean) + expect(fieldMetaValues.length).toBeGreaterThan(0) + expect(fieldMetaValues.every((meta) => meta !== undefined)).toBe(true) + }) + + it('should type-check correctly with TypeScript', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + const meta = form.state.fieldMeta.name + + if (meta) { + const isValid: boolean = meta.isValid + const errors: unknown[] = meta.errors + expect(isValid).toBeDefined() + expect(errors).toBeDefined() + } + + const isValid = form.state.fieldMeta.name?.isValid + const errors = form.state.fieldMeta.name?.errors + + expect(isValid === undefined || typeof isValid === 'boolean').toBe(true) + expect(errors === undefined || Array.isArray(errors)).toBe(true) + }) +}) From 74a9d29fc600424dccc53f81c4507be44ca74bb5 Mon Sep 17 00:00:00 2001 From: Ashish Dabral Date: Sat, 4 Oct 2025 11:40:45 +0530 Subject: [PATCH 2/3] chore: add changeset --- .changeset/bumpy-boats-roll.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/bumpy-boats-roll.md diff --git a/.changeset/bumpy-boats-roll.md b/.changeset/bumpy-boats-roll.md new file mode 100644 index 000000000..0444321c0 --- /dev/null +++ b/.changeset/bumpy-boats-roll.md @@ -0,0 +1,25 @@ +--- +'@tanstack/form-core': major +--- + +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 +} \ No newline at end of file From df8f012613fa6929a3487d40fd4b9eac6177fda0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:26:28 +0000 Subject: [PATCH 3/3] ci: apply automated fixes and generate docs --- .changeset/bumpy-boats-roll.md | 4 +++- packages/form-core/src/FormApi.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/bumpy-boats-roll.md b/.changeset/bumpy-boats-roll.md index 0444321c0..56fa437b6 100644 --- a/.changeset/bumpy-boats-roll.md +++ b/.changeset/bumpy-boats-roll.md @@ -11,6 +11,7 @@ BREAKING CHANGE: `fieldMeta` values are now typed as `Record, An **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 @@ -22,4 +23,5 @@ const isValid = form.state.fieldMeta.name?.isValid const fieldMeta = form.state.fieldMeta.name if (fieldMeta) { const isValid = fieldMeta.isValid -} \ No newline at end of file +} +``` diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 35a7c3b05..7501678b2 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -929,7 +929,9 @@ export class FormApi< TOnServer > > - fieldMetaDerived!: Derived, AnyFieldMeta | undefined>> + fieldMetaDerived!: Derived< + Record, AnyFieldMeta | undefined> + > store!: Derived< FormState< TFormData,