Skip to content

Commit 8ede6d0

Browse files
DexAsHisHautofix-ci[bot]LeCarbonator
authored
fix(form-core): make fieldMeta values optional to reflect runtime behaviour. (#1787)
* fix(form-core): make fieldMeta values optional to reflect runtime behavior fieldMeta is typed as Record<DeepKeys<TData>, 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 * chore: add changeset * ci: apply automated fixes and generate docs * refactor: address review feedback, change to patch version and clean up tests * ci: apply automated fixes and generate docs * fix: sort imports alphabetically * chore: remove unused eslint directives * Adjust changeset description --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: LeCarbonator <[email protected]>
1 parent d51d100 commit 8ede6d0

File tree

4 files changed

+252
-20
lines changed

4 files changed

+252
-20
lines changed

.changeset/bumpy-boats-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
- Make `fieldMeta` record type `Partial<>` to reflect runtime behaviour

packages/form-core/src/FormApi.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export type BaseFormState<
618618
/**
619619
* A record of field metadata for each field in the form, not including the derived properties, like `errors` and such
620620
*/
621-
fieldMetaBase: Record<DeepKeys<TFormData>, AnyFieldMetaBase>
621+
fieldMetaBase: Partial<Record<DeepKeys<TFormData>, AnyFieldMetaBase>>
622622
/**
623623
* A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called.
624624
*
@@ -733,7 +733,7 @@ export type DerivedFormState<
733733
/**
734734
* A record of field metadata for each field in the form.
735735
*/
736-
fieldMeta: Record<DeepKeys<TFormData>, AnyFieldMeta>
736+
fieldMeta: Partial<Record<DeepKeys<TFormData>, AnyFieldMeta>>
737737
}
738738

739739
export interface FormState<
@@ -924,8 +924,22 @@ export class FormApi<
924924
TOnServer
925925
>
926926
>
927-
fieldMetaDerived!: Derived<Record<DeepKeys<TFormData>, AnyFieldMeta>>
928-
store!: Derived<
927+
fieldMetaDerived: Derived<
928+
FormState<
929+
TFormData,
930+
TOnMount,
931+
TOnChange,
932+
TOnChangeAsync,
933+
TOnBlur,
934+
TOnBlurAsync,
935+
TOnSubmit,
936+
TOnSubmitAsync,
937+
TOnDynamic,
938+
TOnDynamicAsync,
939+
TOnServer
940+
>['fieldMeta']
941+
>
942+
store: Derived<
929943
FormState<
930944
TFormData,
931945
TOnMount,
@@ -1019,7 +1033,7 @@ export class FormApi<
10191033

10201034
let originalMetaCount = 0
10211035

1022-
const fieldMeta = {} as FormState<
1036+
const fieldMeta: FormState<
10231037
TFormData,
10241038
TOnMount,
10251039
TOnChange,
@@ -1031,7 +1045,7 @@ export class FormApi<
10311045
TOnDynamic,
10321046
TOnDynamicAsync,
10331047
TOnServer
1034-
>['fieldMeta']
1048+
>['fieldMeta'] = {}
10351049

10361050
for (const fieldName of Object.keys(
10371051
currBaseStore.fieldMetaBase,
@@ -1652,7 +1666,6 @@ export class FormApi<
16521666
for (const field of Object.keys(
16531667
this.state.fieldMeta,
16541668
) as DeepKeys<TFormData>[]) {
1655-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
16561669
if (this.baseStore.state.fieldMetaBase[field] === undefined) {
16571670
continue
16581671
}
@@ -1860,7 +1873,6 @@ export class FormApi<
18601873
for (const field of Object.keys(
18611874
this.state.fieldMeta,
18621875
) as DeepKeys<TFormData>[]) {
1863-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
18641876
if (this.baseStore.state.fieldMetaBase[field] === undefined) {
18651877
continue
18661878
}
@@ -2215,15 +2227,15 @@ export class FormApi<
22152227
* resets every field's meta
22162228
*/
22172229
resetFieldMeta = <TField extends DeepKeys<TFormData>>(
2218-
fieldMeta: Record<TField, AnyFieldMeta>,
2219-
): Record<TField, AnyFieldMeta> => {
2230+
fieldMeta: Partial<Record<TField, AnyFieldMeta>>,
2231+
): Partial<Record<TField, AnyFieldMeta>> => {
22202232
return Object.keys(fieldMeta).reduce(
2221-
(acc: Record<TField, AnyFieldMeta>, key) => {
2233+
(acc, key) => {
22222234
const fieldKey = key as TField
22232235
acc[fieldKey] = defaultFieldMeta
22242236
return acc
22252237
},
2226-
{} as Record<TField, AnyFieldMeta>,
2238+
{} as Partial<Record<TField, AnyFieldMeta>>,
22272239
)
22282240
}
22292241

packages/form-core/tests/FormApi.spec.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,35 @@ describe('form api', () => {
169169
expect(form.state.values).toEqual({ name: 'initial' })
170170
})
171171

172+
it('should handle multiple fields with mixed mount states', () => {
173+
const form = new FormApi({
174+
defaultValues: {
175+
firstName: '',
176+
lastName: '',
177+
email: '',
178+
},
179+
})
180+
181+
const firstNameField = new FieldApi({
182+
form,
183+
name: 'firstName',
184+
})
185+
186+
firstNameField.mount()
187+
188+
expect(form.state.fieldMeta.firstName).toBeDefined()
189+
190+
expect(form.state.fieldMeta.email).toBeUndefined()
191+
192+
const lastNameField = new FieldApi({
193+
form,
194+
name: 'lastName',
195+
})
196+
lastNameField.mount()
197+
198+
expect(form.state.fieldMeta.lastName).toBeDefined()
199+
})
200+
172201
it("should get a field's value", () => {
173202
const form = new FormApi({
174203
defaultValues: {
@@ -1691,10 +1720,10 @@ describe('form api', () => {
16911720
await form.handleSubmit()
16921721
expect(form.state.isFieldsValid).toEqual(false)
16931722
expect(form.state.canSubmit).toEqual(false)
1694-
expect(form.state.fieldMeta['firstName'].errors).toEqual([
1723+
expect(form.state.fieldMeta['firstName']!.errors).toEqual([
16951724
'first name is required',
16961725
])
1697-
expect(form.state.fieldMeta['lastName'].errors).toEqual([
1726+
expect(form.state.fieldMeta['lastName']!.errors).toEqual([
16981727
'last name is required',
16991728
])
17001729
})
@@ -1730,10 +1759,10 @@ describe('form api', () => {
17301759
await form.handleSubmit()
17311760
expect(form.state.isFieldsValid).toEqual(false)
17321761
expect(form.state.canSubmit).toEqual(false)
1733-
expect(form.state.fieldMeta['person.firstName'].errors).toEqual([
1762+
expect(form.state.fieldMeta['person.firstName']!.errors).toEqual([
17341763
'first name is required',
17351764
])
1736-
expect(form.state.fieldMeta['person.lastName'].errors).toEqual([
1765+
expect(form.state.fieldMeta['person.lastName']!.errors).toEqual([
17371766
'last name is required',
17381767
])
17391768
})
@@ -1764,7 +1793,7 @@ describe('form api', () => {
17641793
await form.handleSubmit()
17651794
expect(form.state.isFieldsValid).toEqual(false)
17661795
expect(form.state.canSubmit).toEqual(false)
1767-
expect(form.state.fieldMeta['firstName'].errors).toEqual([
1796+
expect(form.state.fieldMeta['firstName']!.errors).toEqual([
17681797
'first name is required',
17691798
'first name must be longer than 3 characters',
17701799
])
@@ -1873,7 +1902,7 @@ describe('form api', () => {
18731902
await vi.runAllTimersAsync()
18741903
expect(form.state.isFieldsValid).toEqual(false)
18751904
expect(form.state.canSubmit).toEqual(false)
1876-
expect(form.state.fieldMeta['firstName'].errorMap).toEqual({
1905+
expect(form.state.fieldMeta['firstName']!.errorMap).toEqual({
18771906
onChange: 'first name is required',
18781907
onBlur: 'first name must be longer than 3 characters',
18791908
})
@@ -1900,14 +1929,14 @@ describe('form api', () => {
19001929
await form.handleSubmit()
19011930
expect(form.state.isFieldsValid).toEqual(false)
19021931
expect(form.state.canSubmit).toEqual(false)
1903-
expect(form.state.fieldMeta['firstName'].errorMap['onSubmit']).toEqual(
1932+
expect(form.state.fieldMeta['firstName']!.errorMap['onSubmit']).toEqual(
19041933
'first name is required',
19051934
)
19061935
field.handleChange('test')
19071936
expect(form.state.isFieldsValid).toEqual(true)
19081937
expect(form.state.canSubmit).toEqual(true)
19091938
expect(
1910-
form.state.fieldMeta['firstName'].errorMap['onSubmit'],
1939+
form.state.fieldMeta['firstName']!.errorMap['onSubmit'],
19111940
).toBeUndefined()
19121941
})
19131942

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { FieldApi, FormApi } from '../src/index'
3+
4+
describe('fieldMeta accessing', () => {
5+
it('should return undefined for unmounted fields', () => {
6+
const form = new FormApi({
7+
defaultValues: {
8+
name: '',
9+
email: '',
10+
},
11+
})
12+
13+
expect(form.state.fieldMeta.name).toBeUndefined()
14+
expect(form.state.fieldMeta.email).toBeUndefined()
15+
})
16+
17+
it('should have defined fieldMeta after field is mounted', () => {
18+
const form = new FormApi({
19+
defaultValues: {
20+
name: '',
21+
},
22+
})
23+
24+
const field = new FieldApi({
25+
form,
26+
name: 'name',
27+
})
28+
29+
field.mount()
30+
31+
expect(form.state.fieldMeta.name).toBeDefined()
32+
expect(form.state.fieldMeta.name?.isValid).toBe(true)
33+
expect(form.state.fieldMeta.name?.isTouched).toBe(false)
34+
expect(form.state.fieldMeta.name?.isDirty).toBe(false)
35+
})
36+
37+
it('should handle nested field paths', () => {
38+
const form = new FormApi({
39+
defaultValues: {
40+
user: {
41+
profile: {
42+
firstName: '',
43+
lastName: '',
44+
},
45+
},
46+
},
47+
})
48+
49+
expect(form.state.fieldMeta['user.profile.firstName']).toBeUndefined()
50+
expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined()
51+
52+
const firstNameField = new FieldApi({
53+
form,
54+
name: 'user.profile.firstName',
55+
})
56+
57+
firstNameField.mount()
58+
59+
expect(form.state.fieldMeta['user.profile.firstName']).toBeDefined()
60+
61+
expect(form.state.fieldMeta['user.profile.lastName']).toBeUndefined()
62+
})
63+
64+
it('should handle array fields', () => {
65+
const form = new FormApi({
66+
defaultValues: {
67+
items: ['item1', 'item2'],
68+
},
69+
})
70+
71+
expect(form.state.fieldMeta['items[0]']).toBeUndefined()
72+
expect(form.state.fieldMeta['items[1]']).toBeUndefined()
73+
74+
const field0 = new FieldApi({
75+
form,
76+
name: 'items[0]',
77+
})
78+
79+
field0.mount()
80+
81+
expect(form.state.fieldMeta['items[0]']).toBeDefined()
82+
expect(form.state.fieldMeta['items[1]']).toBeUndefined()
83+
})
84+
85+
it('should handle getFieldMeta returning undefined', () => {
86+
const form = new FormApi({
87+
defaultValues: {
88+
name: '',
89+
},
90+
})
91+
92+
const fieldMeta = form.getFieldMeta('name')
93+
expect(fieldMeta).toBeUndefined()
94+
95+
const field = new FieldApi({
96+
form,
97+
name: 'name',
98+
})
99+
100+
field.mount()
101+
102+
const fieldMetaAfterMount = form.getFieldMeta('name')
103+
expect(fieldMetaAfterMount).toBeDefined()
104+
expect(fieldMetaAfterMount?.isValid).toBe(true)
105+
})
106+
107+
it('should handle multiple fields with mixed mount states', () => {
108+
const form = new FormApi({
109+
defaultValues: {
110+
firstName: '',
111+
lastName: '',
112+
email: '',
113+
},
114+
})
115+
116+
const firstNameField = new FieldApi({
117+
form,
118+
name: 'firstName',
119+
})
120+
121+
const lastNameField = new FieldApi({
122+
form,
123+
name: 'lastName',
124+
})
125+
126+
firstNameField.mount()
127+
128+
expect(form.state.fieldMeta.firstName).toBeDefined()
129+
expect(form.state.fieldMeta.email).toBeUndefined()
130+
})
131+
132+
it('should preserve fieldMeta after unmounting and remounting', () => {
133+
const form = new FormApi({
134+
defaultValues: {
135+
name: '',
136+
},
137+
})
138+
139+
const field = new FieldApi({
140+
form,
141+
name: 'name',
142+
})
143+
144+
const cleanup = field.mount()
145+
146+
field.setValue('test')
147+
expect(form.state.fieldMeta.name?.isTouched).toBe(true)
148+
expect(form.state.fieldMeta.name?.isDirty).toBe(true)
149+
150+
cleanup()
151+
152+
const metaAfterCleanup = form.state.fieldMeta.name
153+
154+
expect(metaAfterCleanup).toBeDefined()
155+
})
156+
157+
it('should work with form validation that accesses fieldMeta', () => {
158+
const form = new FormApi({
159+
defaultValues: {
160+
password: '',
161+
confirmPassword: '',
162+
},
163+
validators: {
164+
onChange: ({ value }) => {
165+
if (value.password !== value.confirmPassword) {
166+
return 'Passwords must match'
167+
}
168+
return undefined
169+
},
170+
},
171+
})
172+
173+
form.mount()
174+
175+
const passwordField = new FieldApi({
176+
form,
177+
name: 'password',
178+
})
179+
180+
passwordField.mount()
181+
182+
expect(() => {
183+
passwordField.setValue('test123')
184+
}).not.toThrow()
185+
})
186+
})

0 commit comments

Comments
 (0)