Skip to content

Commit 337acd8

Browse files
authored
fix(form-core): fix broken sync/async validation logic (#1370)
This commit updates sync and async validation state to shift with FieldMeta Fixes "Cannot read properties of undefined" when removing array field #1323
1 parent 527c19a commit 337acd8

File tree

8 files changed

+977
-111
lines changed

8 files changed

+977
-111
lines changed

packages/form-core/src/FieldApi.ts

+67-32
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
standardSchemaValidators,
55
} from './standardSchemaValidator'
66
import { defaultFieldMeta } from './metaHelper'
7-
import { getAsyncValidatorArray, getBy, getSyncValidatorArray } from './utils'
7+
import {
8+
determineFieldLevelErrorSourceAndValue,
9+
getAsyncValidatorArray,
10+
getBy,
11+
getSyncValidatorArray,
12+
} from './utils'
813
import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types'
914
import type {
1015
StandardSchemaV1,
@@ -25,6 +30,7 @@ import type {
2530
ValidationCause,
2631
ValidationError,
2732
ValidationErrorMap,
33+
ValidationErrorMapSource,
2834
} from './types'
2935
import type { AsyncValidator, SyncValidator, Updater } from './utils'
3036

@@ -561,6 +567,10 @@ export type FieldMetaBase<
561567
UnwrapFieldValidateOrFn<TName, TOnSubmit, TFormOnSubmit>,
562568
UnwrapFieldAsyncValidateOrFn<TName, TOnSubmitAsync, TFormOnSubmitAsync>
563569
>
570+
/**
571+
* @private allows tracking the source of the errors in the error map
572+
*/
573+
errorSourceMap: ValidationErrorMapSource
564574
/**
565575
* A flag indicating whether the field is currently being validated.
566576
*/
@@ -1101,6 +1111,11 @@ export class FieldApi<
11011111
...prev,
11021112
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
11031113
errorMap: { ...prev?.errorMap, onMount: error },
1114+
errorSourceMap: {
1115+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1116+
...prev?.errorSourceMap,
1117+
onMount: 'field',
1118+
},
11041119
}) as never,
11051120
)
11061121
}
@@ -1345,39 +1360,43 @@ export class FieldApi<
13451360
) => {
13461361
const errorMapKey = getErrorMapKey(validateObj.cause)
13471362

1348-
const error =
1349-
/*
1350-
If `validateObj.validate` is `undefined`, then the field doesn't have
1351-
a validator for this event, but there still could be an error that
1352-
needs to be cleaned up related to the current event left by the
1353-
form's validator.
1354-
*/
1355-
validateObj.validate
1356-
? normalizeError(
1357-
field.runValidator({
1358-
validate: validateObj.validate,
1359-
value: {
1360-
value: field.store.state.value,
1361-
validationSource: 'field',
1362-
fieldApi: field,
1363-
},
1364-
type: 'validate',
1365-
}),
1366-
)
1367-
: errorFromForm[errorMapKey]
1363+
const fieldLevelError = validateObj.validate
1364+
? normalizeError(
1365+
field.runValidator({
1366+
validate: validateObj.validate,
1367+
value: {
1368+
value: field.store.state.value,
1369+
validationSource: 'field',
1370+
fieldApi: field,
1371+
},
1372+
type: 'validate',
1373+
}),
1374+
)
1375+
: undefined
13681376

1369-
if (field.state.meta.errorMap[errorMapKey] !== error) {
1377+
const formLevelError = errorFromForm[errorMapKey]
1378+
1379+
const { newErrorValue, newSource } =
1380+
determineFieldLevelErrorSourceAndValue({
1381+
formLevelError,
1382+
fieldLevelError,
1383+
})
1384+
1385+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1386+
if (field.state.meta.errorMap?.[errorMapKey] !== newErrorValue) {
13701387
field.setMeta((prev) => ({
13711388
...prev,
13721389
errorMap: {
13731390
...prev.errorMap,
1374-
[getErrorMapKey(validateObj.cause)]:
1375-
// Prefer the error message from the field validators if they exist
1376-
error ? error : errorFromForm[errorMapKey],
1391+
[errorMapKey]: newErrorValue,
1392+
},
1393+
errorSourceMap: {
1394+
...prev.errorSourceMap,
1395+
[errorMapKey]: newSource,
13771396
},
13781397
}))
13791398
}
1380-
if (error || errorFromForm[errorMapKey]) {
1399+
if (newErrorValue) {
13811400
hasErrored = true
13821401
}
13831402
}
@@ -1398,7 +1417,8 @@ export class FieldApi<
13981417
const submitErrKey = getErrorMapKey('submit')
13991418

14001419
if (
1401-
this.state.meta.errorMap[submitErrKey] &&
1420+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1421+
this.state.meta.errorMap?.[submitErrKey] &&
14021422
cause !== 'submit' &&
14031423
!hasErrored
14041424
) {
@@ -1408,6 +1428,10 @@ export class FieldApi<
14081428
...prev.errorMap,
14091429
[submitErrKey]: undefined,
14101430
},
1431+
errorSourceMap: {
1432+
...prev.errorSourceMap,
1433+
[submitErrKey]: undefined,
1434+
},
14111435
}))
14121436
}
14131437

@@ -1521,22 +1545,33 @@ export class FieldApi<
15211545
rawError = e as ValidationError
15221546
}
15231547
if (controller.signal.aborted) return resolve(undefined)
1524-
const error = normalizeError(rawError)
1525-
const fieldErrorFromForm =
1548+
1549+
const fieldLevelError = normalizeError(rawError)
1550+
const formLevelError =
15261551
asyncFormValidationResults[this.name]?.[errorMapKey]
1527-
const fieldError = error || fieldErrorFromForm
1552+
1553+
const { newErrorValue, newSource } =
1554+
determineFieldLevelErrorSourceAndValue({
1555+
formLevelError,
1556+
fieldLevelError,
1557+
})
1558+
15281559
field.setMeta((prev) => {
15291560
return {
15301561
...prev,
15311562
errorMap: {
15321563
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
15331564
...prev?.errorMap,
1534-
[errorMapKey]: fieldError,
1565+
[errorMapKey]: newErrorValue,
1566+
},
1567+
errorSourceMap: {
1568+
...prev.errorSourceMap,
1569+
[errorMapKey]: newSource,
15351570
},
15361571
}
15371572
})
15381573

1539-
resolve(fieldError)
1574+
resolve(newErrorValue)
15401575
}),
15411576
)
15421577
}

packages/form-core/src/FormApi.ts

+88-68
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+
determineFormLevelErrorSourceAndValue,
45
functionalUpdate,
56
getAsyncValidatorArray,
67
getBy,
@@ -733,22 +734,6 @@ export class FormApi<
733734
*/
734735
prevTransformArray: unknown[] = []
735736

736-
/**
737-
* @private Persistent store of all field validation errors originating from form-level validators.
738-
* Maintains the cumulative state across validation cycles, including cleared errors (undefined values).
739-
* This map preserves the complete validation state for all fields.
740-
*/
741-
cumulativeFieldsErrorMap: FormErrorMapFromValidator<
742-
TFormData,
743-
TOnMount,
744-
TOnChange,
745-
TOnChangeAsync,
746-
TOnBlur,
747-
TOnBlurAsync,
748-
TOnSubmit,
749-
TOnSubmitAsync
750-
> = {}
751-
752737
/**
753738
* Constructs a new `FormApi` instance with the given form options.
754739
*/
@@ -1306,56 +1291,56 @@ export class FormApi<
13061291

13071292
const errorMapKey = getErrorMapKey(validateObj.cause)
13081293

1309-
if (fieldErrors) {
1310-
for (const [field, fieldError] of Object.entries(fieldErrors) as [
1311-
DeepKeys<TFormData>,
1312-
ValidationError,
1313-
][]) {
1314-
const oldErrorMap = this.cumulativeFieldsErrorMap[field] || {}
1315-
const newErrorMap = {
1316-
...oldErrorMap,
1317-
[errorMapKey]: fieldError,
1318-
}
1319-
currentValidationErrorMap[field] = newErrorMap
1320-
this.cumulativeFieldsErrorMap[field] = newErrorMap
1294+
for (const field of Object.keys(
1295+
this.state.fieldMeta,
1296+
) as DeepKeys<TFormData>[]) {
1297+
const fieldMeta = this.getFieldMeta(field)
1298+
if (!fieldMeta) continue
1299+
1300+
const {
1301+
errorMap: currentErrorMap,
1302+
errorSourceMap: currentErrorMapSource,
1303+
} = fieldMeta
1304+
1305+
const newFormValidatorError = fieldErrors?.[field]
1306+
1307+
const { newErrorValue, newSource } =
1308+
determineFormLevelErrorSourceAndValue({
1309+
newFormValidatorError,
1310+
isPreviousErrorFromFormValidator:
1311+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1312+
currentErrorMapSource?.[errorMapKey] === 'form',
1313+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1314+
previousErrorValue: currentErrorMap?.[errorMapKey],
1315+
})
13211316

1322-
const fieldMeta = this.getFieldMeta(field)
1323-
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
1324-
this.setFieldMeta(field, (prev) => ({
1325-
...prev,
1326-
errorMap: {
1327-
...prev.errorMap,
1328-
[errorMapKey]: fieldError,
1329-
},
1330-
}))
1317+
if (newSource === 'form') {
1318+
currentValidationErrorMap[field] = {
1319+
...currentValidationErrorMap[field],
1320+
[errorMapKey]: newFormValidatorError,
13311321
}
13321322
}
1333-
}
13341323

1335-
for (const field of Object.keys(this.cumulativeFieldsErrorMap) as Array<
1336-
DeepKeys<TFormData>
1337-
>) {
1338-
const fieldMeta = this.getFieldMeta(field)
13391324
if (
1340-
fieldMeta?.errorMap[errorMapKey] &&
1341-
!currentValidationErrorMap[field]?.[errorMapKey]
1325+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1326+
currentErrorMap?.[errorMapKey] !== newErrorValue
13421327
) {
1343-
this.cumulativeFieldsErrorMap[field] = {
1344-
...this.cumulativeFieldsErrorMap[field],
1345-
[errorMapKey]: undefined,
1346-
}
1347-
13481328
this.setFieldMeta(field, (prev) => ({
13491329
...prev,
13501330
errorMap: {
13511331
...prev.errorMap,
1352-
[errorMapKey]: undefined,
1332+
[errorMapKey]: newErrorValue,
1333+
},
1334+
errorSourceMap: {
1335+
...prev.errorSourceMap,
1336+
[errorMapKey]: newSource,
13531337
},
13541338
}))
13551339
}
13561340
}
13571341

1358-
if (this.state.errorMap[errorMapKey] !== formError) {
1342+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1343+
if (this.state.errorMap?.[errorMapKey] !== formError) {
13591344
this.baseStore.setState((prev) => ({
13601345
...prev,
13611346
errorMap: {
@@ -1376,7 +1361,8 @@ export class FormApi<
13761361
*/
13771362
const submitErrKey = getErrorMapKey('submit')
13781363
if (
1379-
this.state.errorMap[submitErrKey] &&
1364+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1365+
this.state.errorMap?.[submitErrKey] &&
13801366
cause !== 'submit' &&
13811367
!hasErrored
13821368
) {
@@ -1422,7 +1408,7 @@ export class FormApi<
14221408
*/
14231409
const promises: Promise<ValidationPromiseResult<TFormData>>[] = []
14241410

1425-
let fieldErrors:
1411+
let fieldErrorsFromFormValidators:
14261412
| Partial<Record<DeepKeys<TFormData>, ValidationError>>
14271413
| undefined
14281414

@@ -1473,26 +1459,56 @@ export class FormApi<
14731459
normalizeError<TFormData>(rawError)
14741460

14751461
if (fieldErrorsFromNormalizeError) {
1476-
fieldErrors = fieldErrors
1477-
? { ...fieldErrors, ...fieldErrorsFromNormalizeError }
1462+
fieldErrorsFromFormValidators = fieldErrorsFromFormValidators
1463+
? {
1464+
...fieldErrorsFromFormValidators,
1465+
...fieldErrorsFromNormalizeError,
1466+
}
14781467
: fieldErrorsFromNormalizeError
14791468
}
14801469
const errorMapKey = getErrorMapKey(validateObj.cause)
14811470

1482-
if (fieldErrors) {
1483-
for (const [field, fieldError] of Object.entries(fieldErrors)) {
1484-
const fieldMeta = this.getFieldMeta(field as DeepKeys<TFormData>)
1485-
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
1486-
this.setFieldMeta(field as DeepKeys<TFormData>, (prev) => ({
1487-
...prev,
1488-
errorMap: {
1489-
...prev.errorMap,
1490-
[errorMapKey]: fieldError,
1491-
},
1492-
}))
1493-
}
1471+
for (const field of Object.keys(
1472+
this.state.fieldMeta,
1473+
) as DeepKeys<TFormData>[]) {
1474+
const fieldMeta = this.getFieldMeta(field)
1475+
if (!fieldMeta) continue
1476+
1477+
const {
1478+
errorMap: currentErrorMap,
1479+
errorSourceMap: currentErrorMapSource,
1480+
} = fieldMeta
1481+
1482+
const newFormValidatorError = fieldErrorsFromFormValidators?.[field]
1483+
1484+
const { newErrorValue, newSource } =
1485+
determineFormLevelErrorSourceAndValue({
1486+
newFormValidatorError,
1487+
isPreviousErrorFromFormValidator:
1488+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1489+
currentErrorMapSource?.[errorMapKey] === 'form',
1490+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1491+
previousErrorValue: currentErrorMap?.[errorMapKey],
1492+
})
1493+
1494+
if (
1495+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1496+
currentErrorMap?.[errorMapKey] !== newErrorValue
1497+
) {
1498+
this.setFieldMeta(field, (prev) => ({
1499+
...prev,
1500+
errorMap: {
1501+
...prev.errorMap,
1502+
[errorMapKey]: newErrorValue,
1503+
},
1504+
errorSourceMap: {
1505+
...prev.errorSourceMap,
1506+
[errorMapKey]: newSource,
1507+
},
1508+
}))
14941509
}
14951510
}
1511+
14961512
this.baseStore.setState((prev) => ({
14971513
...prev,
14981514
errorMap: {
@@ -1501,7 +1517,11 @@ export class FormApi<
15011517
},
15021518
}))
15031519

1504-
resolve(fieldErrors ? { fieldErrors, errorMapKey } : undefined)
1520+
resolve(
1521+
fieldErrorsFromFormValidators
1522+
? { fieldErrors: fieldErrorsFromFormValidators, errorMapKey }
1523+
: undefined,
1524+
)
15051525
}),
15061526
)
15071527
}

0 commit comments

Comments
 (0)