Skip to content

Commit 2fdca86

Browse files
committed
fix(form-core): fix broken sync/async validation logic
This commit updates async validators to ensure source map is synced with full coverage. Fixes "Cannot read properties of undefined" when removing array field #1323
1 parent 7d02af2 commit 2fdca86

File tree

8 files changed

+749
-79
lines changed

8 files changed

+749
-79
lines changed

Diff for: packages/form-core/src/FieldApi.ts

+30
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
ValidationCause,
2525
ValidationError,
2626
ValidationErrorMap,
27+
ValidationErrorMapSource,
2728
} from './types'
2829
import type { AsyncValidator, SyncValidator, Updater } from './utils'
2930

@@ -561,6 +562,10 @@ export type FieldMetaBase<
561562
TFormOnSubmitAsync
562563
>
563564
>
565+
/**
566+
* @private allows tracking the source of the errors in the error map
567+
*/
568+
errorSourceMap: ValidationErrorMapSource
564569
/**
565570
* A flag indicating whether the field is currently being validated.
566571
*/
@@ -1096,6 +1101,11 @@ export class FieldApi<
10961101
...prev,
10971102
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
10981103
errorMap: { ...prev?.errorMap, onMount: error },
1104+
errorSourceMap: {
1105+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1106+
...prev?.errorSourceMap,
1107+
onMount: 'field',
1108+
},
10991109
}) as never,
11001110
)
11011111
}
@@ -1389,6 +1399,14 @@ export class FieldApi<
13891399
// Prefer the error message from the field validators if they exist
13901400
error ? error : errorFromForm[errorMapKey],
13911401
},
1402+
errorSourceMap: {
1403+
...prev.errorSourceMap,
1404+
[getErrorMapKey(validateObj.cause)]: error
1405+
? 'field'
1406+
: errorFromForm[errorMapKey]
1407+
? 'form'
1408+
: undefined,
1409+
},
13921410
}))
13931411
}
13941412
if (error || errorFromForm[errorMapKey]) {
@@ -1422,6 +1440,10 @@ export class FieldApi<
14221440
...prev.errorMap,
14231441
[submitErrKey]: undefined,
14241442
},
1443+
errorSourceMap: {
1444+
...prev.errorSourceMap,
1445+
[submitErrKey]: undefined,
1446+
},
14251447
}))
14261448
}
14271449

@@ -1544,6 +1566,14 @@ export class FieldApi<
15441566
...prev?.errorMap,
15451567
[errorMapKey]: fieldError,
15461568
},
1569+
errorSourceMap: {
1570+
...prev.errorSourceMap,
1571+
[errorMapKey]: error
1572+
? 'field'
1573+
: fieldErrorFromForm
1574+
? 'form'
1575+
: undefined,
1576+
},
15471577
}
15481578
})
15491579

Diff for: packages/form-core/src/FormApi.ts

+72-62
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Derived, Store, batch } from '@tanstack/store'
22
import {
33
deleteBy,
4+
determineErrorValue,
45
functionalUpdate,
56
getAsyncValidatorArray,
67
getBy,
@@ -729,22 +730,6 @@ export class FormApi<
729730
*/
730731
prevTransformArray: unknown[] = []
731732

732-
/**
733-
* @private Persistent store of all field validation errors originating from form-level validators.
734-
* Maintains the cumulative state across validation cycles, including cleared errors (undefined values).
735-
* This map preserves the complete validation state for all fields.
736-
*/
737-
cumulativeFieldsErrorMap: FormErrorMapFromValidator<
738-
TFormData,
739-
TOnMount,
740-
TOnChange,
741-
TOnChangeAsync,
742-
TOnBlur,
743-
TOnBlurAsync,
744-
TOnSubmit,
745-
TOnSubmitAsync
746-
> = {}
747-
748733
/**
749734
* Constructs a new `FormApi` instance with the given form options.
750735
*/
@@ -1300,50 +1285,48 @@ export class FormApi<
13001285

13011286
const errorMapKey = getErrorMapKey(validateObj.cause)
13021287

1303-
if (fieldErrors) {
1304-
for (const [field, fieldError] of Object.entries(fieldErrors) as [
1305-
DeepKeys<TFormData>,
1306-
ValidationError,
1307-
][]) {
1308-
const oldErrorMap = this.cumulativeFieldsErrorMap[field] || {}
1309-
const newErrorMap = {
1310-
...oldErrorMap,
1311-
[errorMapKey]: fieldError,
1312-
}
1313-
currentValidationErrorMap[field] = newErrorMap
1314-
this.cumulativeFieldsErrorMap[field] = newErrorMap
1288+
for (const field of Object.keys(
1289+
this.state.fieldMeta,
1290+
) as DeepKeys<TFormData>[]) {
1291+
const fieldMeta = this.getFieldMeta(field)
1292+
if (!fieldMeta) continue
13151293

1316-
const fieldMeta = this.getFieldMeta(field)
1317-
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
1318-
this.setFieldMeta(field, (prev) => ({
1319-
...prev,
1320-
errorMap: {
1321-
...prev.errorMap,
1322-
[errorMapKey]: fieldError,
1323-
},
1324-
}))
1294+
const {
1295+
errorMap: currentErrorMap,
1296+
errorSourceMap: currentErrorMapSource,
1297+
} = fieldMeta
1298+
1299+
const newFormValidatorError = fieldErrors?.[field]
1300+
1301+
const { newErrorValue, newSource } = determineErrorValue({
1302+
newFormValidatorError,
1303+
isPreviousErrorFromFormValidator:
1304+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1305+
currentErrorMapSource?.[errorMapKey] === 'form',
1306+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1307+
previousErrorValue: currentErrorMap?.[errorMapKey],
1308+
})
1309+
1310+
if (newSource === 'form') {
1311+
currentValidationErrorMap[field] = {
1312+
...currentValidationErrorMap[field],
1313+
[errorMapKey]: newFormValidatorError,
13251314
}
13261315
}
1327-
}
13281316

1329-
for (const field of Object.keys(this.cumulativeFieldsErrorMap) as Array<
1330-
DeepKeys<TFormData>
1331-
>) {
1332-
const fieldMeta = this.getFieldMeta(field)
13331317
if (
1334-
fieldMeta?.errorMap[errorMapKey] &&
1335-
!currentValidationErrorMap[field]?.[errorMapKey]
1318+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1319+
currentErrorMap?.[errorMapKey] !== newErrorValue
13361320
) {
1337-
this.cumulativeFieldsErrorMap[field] = {
1338-
...this.cumulativeFieldsErrorMap[field],
1339-
[errorMapKey]: undefined,
1340-
}
1341-
13421321
this.setFieldMeta(field, (prev) => ({
13431322
...prev,
13441323
errorMap: {
13451324
...prev.errorMap,
1346-
[errorMapKey]: undefined,
1325+
[errorMapKey]: newErrorValue,
1326+
},
1327+
errorSourceMap: {
1328+
...prev.errorSourceMap,
1329+
[errorMapKey]: newSource,
13471330
},
13481331
}))
13491332
}
@@ -1473,20 +1456,47 @@ export class FormApi<
14731456
}
14741457
const errorMapKey = getErrorMapKey(validateObj.cause)
14751458

1476-
if (fieldErrors) {
1477-
for (const [field, fieldError] of Object.entries(fieldErrors)) {
1478-
const fieldMeta = this.getFieldMeta(field as DeepKeys<TFormData>)
1479-
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
1480-
this.setFieldMeta(field as DeepKeys<TFormData>, (prev) => ({
1481-
...prev,
1482-
errorMap: {
1483-
...prev.errorMap,
1484-
[errorMapKey]: fieldError,
1485-
},
1486-
}))
1487-
}
1459+
// if (fieldErrors) {
1460+
for (const field of Object.keys(
1461+
this.state.fieldMeta,
1462+
) as DeepKeys<TFormData>[]) {
1463+
const fieldMeta = this.getFieldMeta(field)
1464+
if (!fieldMeta) continue
1465+
1466+
const {
1467+
errorMap: currentErrorMap,
1468+
errorSourceMap: currentErrorMapSource,
1469+
} = fieldMeta
1470+
1471+
const newFormValidatorError = fieldErrors?.[field]
1472+
1473+
const { newErrorValue, newSource } = determineErrorValue({
1474+
newFormValidatorError,
1475+
isPreviousErrorFromFormValidator:
1476+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1477+
currentErrorMapSource?.[errorMapKey] === 'form',
1478+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1479+
previousErrorValue: currentErrorMap?.[errorMapKey],
1480+
})
1481+
1482+
if (
1483+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1484+
currentErrorMap?.[errorMapKey] !== newErrorValue
1485+
) {
1486+
this.setFieldMeta(field, (prev) => ({
1487+
...prev,
1488+
errorMap: {
1489+
...prev.errorMap,
1490+
[errorMapKey]: newErrorValue,
1491+
},
1492+
errorSourceMap: {
1493+
...prev.errorSourceMap,
1494+
[errorMapKey]: newSource,
1495+
},
1496+
}))
14881497
}
14891498
}
1499+
14901500
this.baseStore.setState((prev) => ({
14911501
...prev,
14921502
errorMap: {

Diff for: packages/form-core/src/metaHelper.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
1616
isPristine: true,
1717
errors: [],
1818
errorMap: {},
19+
errorSourceMap: {},
1920
}
2021

2122
export function metaHelper<

Diff for: packages/form-core/src/types.ts

+29-6
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,39 @@ export type ValidationErrorMap<
2828
TOnSubmitAsyncReturn = unknown,
2929
TOnServerReturn = unknown,
3030
> = {
31-
onMount?: TOnMountReturn
32-
onChange?: TOnChangeReturn | TOnChangeAsyncReturn
33-
onBlur?: TOnBlurReturn | TOnBlurAsyncReturn
34-
onSubmit?: TOnSubmitReturn | TOnSubmitAsyncReturn
35-
onServer?: TOnServerReturn
31+
onMount?: {
32+
error: TOnMountReturn
33+
source: ValidationSource
34+
}
35+
onChange?: {
36+
error: TOnChangeReturn | TOnChangeAsyncReturn
37+
source: ValidationSource
38+
}
39+
onBlur?: {
40+
error: TOnBlurReturn | TOnBlurAsyncReturn
41+
source: ValidationSource
42+
}
43+
onSubmit?: {
44+
error: TOnSubmitReturn | TOnSubmitAsyncReturn
45+
source: ValidationSource
46+
}
47+
onServer?: {
48+
error: TOnServerReturn
49+
source: ValidationSource
50+
}
3651
}
3752

3853
/**
39-
* @private
54+
* @private allows tracking the source of the errors in the error map
4055
*/
56+
export type ValidationErrorMapSource = {
57+
onMount?: ValidationSource
58+
onChange?: ValidationSource
59+
onBlur?: ValidationSource
60+
onSubmit?: ValidationSource
61+
onServer?: ValidationSource
62+
}
63+
4164
export type FormValidationErrorMap<
4265
TOnMountReturn = unknown,
4366
TOnChangeReturn = unknown,

Diff for: packages/form-core/src/utils.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { GlobalFormValidationError, ValidationCause } from './types'
1+
import type {
2+
GlobalFormValidationError,
3+
ValidationCause,
4+
ValidationError,
5+
} from './types'
26
import type { FormValidators } from './FormApi'
37
import type { AnyFieldMeta, FieldValidators } from './FieldApi'
48

@@ -376,3 +380,30 @@ export function shallow<T>(objA: T, objB: T) {
376380
}
377381
return true
378382
}
383+
384+
/**
385+
* Determines the logic for determining the error value to set on the field meta within the form sync validation.
386+
* @private
387+
*/
388+
export const determineErrorValue = ({
389+
newFormValidatorError,
390+
isPreviousErrorFromFormValidator,
391+
previousErrorValue,
392+
}: {
393+
newFormValidatorError: ValidationError
394+
isPreviousErrorFromFormValidator: boolean
395+
previousErrorValue: ValidationError
396+
}) => {
397+
// All falsy values are not considered errors
398+
if (newFormValidatorError) {
399+
return { newErrorValue: newFormValidatorError, newSource: 'form' }
400+
}
401+
402+
// Clears form level error since it's now stale
403+
if (isPreviousErrorFromFormValidator) {
404+
return { newErrorValue: undefined, newSource: undefined }
405+
}
406+
407+
// Do not clear field level validators since they're handled by the field validator
408+
return { newErrorValue: previousErrorValue, newSource: 'field' }
409+
}

Diff for: packages/form-core/tests/FieldApi.spec.ts

+28
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('field api', () => {
6161
isDirty: false,
6262
errors: [],
6363
errorMap: {},
64+
errorSourceMap: {},
6465
})
6566
})
6667

@@ -90,6 +91,7 @@ describe('field api', () => {
9091
isPristine: false,
9192
errors: [],
9293
errorMap: {},
94+
errorSourceMap: {},
9395
})
9496
})
9597

@@ -1890,4 +1892,30 @@ describe('field api', () => {
18901892
expect(field.getMeta().errors).toStrictEqual([])
18911893
expect(form.state.canSubmit).toBe(true)
18921894
})
1895+
1896+
it('should update the errorSourceMap with field source when field async field error is added', async () => {
1897+
vi.useFakeTimers()
1898+
const form = new FormApi({
1899+
defaultValues: {
1900+
name: 'test',
1901+
},
1902+
})
1903+
form.mount()
1904+
1905+
const field = new FieldApi({
1906+
form,
1907+
name: 'name',
1908+
validators: {
1909+
onChangeAsync: async () => {
1910+
return 'Error'
1911+
},
1912+
},
1913+
})
1914+
field.mount()
1915+
1916+
field.setValue('test')
1917+
await vi.runAllTimersAsync()
1918+
1919+
expect(field.getMeta().errorSourceMap.onChange).toEqual('field')
1920+
})
18931921
})

0 commit comments

Comments
 (0)