Skip to content

fix(form-core): fix broken sync/async validation logic #1370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 67 additions & 32 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import {
standardSchemaValidators,
} from './standardSchemaValidator'
import { defaultFieldMeta } from './metaHelper'
import { getAsyncValidatorArray, getBy, getSyncValidatorArray } from './utils'
import {
determineFieldLevelErrorSourceAndValue,
getAsyncValidatorArray,
getBy,
getSyncValidatorArray,
} from './utils'
import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types'
import type {
StandardSchemaV1,
Expand All @@ -25,6 +30,7 @@ import type {
ValidationCause,
ValidationError,
ValidationErrorMap,
ValidationErrorMapSource,
} from './types'
import type { AsyncValidator, SyncValidator, Updater } from './utils'

Expand Down Expand Up @@ -561,6 +567,10 @@ export type FieldMetaBase<
UnwrapFieldValidateOrFn<TName, TOnSubmit, TFormOnSubmit>,
UnwrapFieldAsyncValidateOrFn<TName, TOnSubmitAsync, TFormOnSubmitAsync>
>
/**
* @private allows tracking the source of the errors in the error map
*/
errorSourceMap: ValidationErrorMapSource
/**
* A flag indicating whether the field is currently being validated.
*/
Expand Down Expand Up @@ -1101,6 +1111,11 @@ export class FieldApi<
...prev,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
errorMap: { ...prev?.errorMap, onMount: error },
errorSourceMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorSourceMap,
onMount: 'field',
},
}) as never,
)
}
Expand Down Expand Up @@ -1345,39 +1360,43 @@ export class FieldApi<
) => {
const errorMapKey = getErrorMapKey(validateObj.cause)

const error =
/*
If `validateObj.validate` is `undefined`, then the field doesn't have
a validator for this event, but there still could be an error that
needs to be cleaned up related to the current event left by the
form's validator.
*/
validateObj.validate
? normalizeError(
field.runValidator({
validate: validateObj.validate,
value: {
value: field.store.state.value,
validationSource: 'field',
fieldApi: field,
},
type: 'validate',
}),
)
: errorFromForm[errorMapKey]
const fieldLevelError = validateObj.validate
? normalizeError(
field.runValidator({
validate: validateObj.validate,
value: {
value: field.store.state.value,
validationSource: 'field',
fieldApi: field,
},
type: 'validate',
}),
)
: undefined

if (field.state.meta.errorMap[errorMapKey] !== error) {
const formLevelError = errorFromForm[errorMapKey]

const { newErrorValue, newSource } =
determineFieldLevelErrorSourceAndValue({
formLevelError,
fieldLevelError,
})

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (field.state.meta.errorMap?.[errorMapKey] !== newErrorValue) {
field.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[getErrorMapKey(validateObj.cause)]:
// Prefer the error message from the field validators if they exist
error ? error : errorFromForm[errorMapKey],
[errorMapKey]: newErrorValue,
},
errorSourceMap: {
...prev.errorSourceMap,
[errorMapKey]: newSource,
},
}))
}
if (error || errorFromForm[errorMapKey]) {
if (newErrorValue) {
hasErrored = true
}
}
Expand All @@ -1398,7 +1417,8 @@ export class FieldApi<
const submitErrKey = getErrorMapKey('submit')

if (
this.state.meta.errorMap[submitErrKey] &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.state.meta.errorMap?.[submitErrKey] &&
cause !== 'submit' &&
!hasErrored
) {
Expand All @@ -1408,6 +1428,10 @@ export class FieldApi<
...prev.errorMap,
[submitErrKey]: undefined,
},
errorSourceMap: {
...prev.errorSourceMap,
[submitErrKey]: undefined,
},
}))
}

Expand Down Expand Up @@ -1521,22 +1545,33 @@ export class FieldApi<
rawError = e as ValidationError
}
if (controller.signal.aborted) return resolve(undefined)
const error = normalizeError(rawError)
const fieldErrorFromForm =

const fieldLevelError = normalizeError(rawError)
const formLevelError =
asyncFormValidationResults[this.name]?.[errorMapKey]
const fieldError = error || fieldErrorFromForm

const { newErrorValue, newSource } =
determineFieldLevelErrorSourceAndValue({
formLevelError,
fieldLevelError,
})

field.setMeta((prev) => {
return {
...prev,
errorMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorMap,
[errorMapKey]: fieldError,
[errorMapKey]: newErrorValue,
},
errorSourceMap: {
...prev.errorSourceMap,
[errorMapKey]: newSource,
},
}
})

resolve(fieldError)
resolve(newErrorValue)
}),
)
}
Expand Down
156 changes: 88 additions & 68 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Derived, Store, batch } from '@tanstack/store'
import {
deleteBy,
determineFormLevelErrorSourceAndValue,
functionalUpdate,
getAsyncValidatorArray,
getBy,
Expand Down Expand Up @@ -733,22 +734,6 @@
*/
prevTransformArray: unknown[] = []

/**
* @private Persistent store of all field validation errors originating from form-level validators.
* Maintains the cumulative state across validation cycles, including cleared errors (undefined values).
* This map preserves the complete validation state for all fields.
*/
cumulativeFieldsErrorMap: FormErrorMapFromValidator<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync
> = {}

/**
* Constructs a new `FormApi` instance with the given form options.
*/
Expand Down Expand Up @@ -1306,56 +1291,56 @@

const errorMapKey = getErrorMapKey(validateObj.cause)

if (fieldErrors) {
for (const [field, fieldError] of Object.entries(fieldErrors) as [
DeepKeys<TFormData>,
ValidationError,
][]) {
const oldErrorMap = this.cumulativeFieldsErrorMap[field] || {}
const newErrorMap = {
...oldErrorMap,
[errorMapKey]: fieldError,
}
currentValidationErrorMap[field] = newErrorMap
this.cumulativeFieldsErrorMap[field] = newErrorMap
for (const field of Object.keys(
this.state.fieldMeta,
) as DeepKeys<TFormData>[]) {
const fieldMeta = this.getFieldMeta(field)
if (!fieldMeta) continue

const {
errorMap: currentErrorMap,
errorSourceMap: currentErrorMapSource,
} = fieldMeta

const newFormValidatorError = fieldErrors?.[field]

const { newErrorValue, newSource } =
determineFormLevelErrorSourceAndValue({
newFormValidatorError,
isPreviousErrorFromFormValidator:
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
currentErrorMapSource?.[errorMapKey] === 'form',
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
previousErrorValue: currentErrorMap?.[errorMapKey],
})

const fieldMeta = this.getFieldMeta(field)
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
this.setFieldMeta(field, (prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[errorMapKey]: fieldError,
},
}))
if (newSource === 'form') {
currentValidationErrorMap[field] = {
...currentValidationErrorMap[field],
[errorMapKey]: newFormValidatorError,
}
}
}

for (const field of Object.keys(this.cumulativeFieldsErrorMap) as Array<
DeepKeys<TFormData>
>) {
const fieldMeta = this.getFieldMeta(field)
if (
fieldMeta?.errorMap[errorMapKey] &&
!currentValidationErrorMap[field]?.[errorMapKey]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
currentErrorMap?.[errorMapKey] !== newErrorValue
) {
this.cumulativeFieldsErrorMap[field] = {
...this.cumulativeFieldsErrorMap[field],
[errorMapKey]: undefined,
}

this.setFieldMeta(field, (prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[errorMapKey]: undefined,
[errorMapKey]: newErrorValue,
},
errorSourceMap: {
...prev.errorSourceMap,
[errorMapKey]: newSource,
},
}))
}
}

if (this.state.errorMap[errorMapKey] !== formError) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.state.errorMap?.[errorMapKey] !== formError) {
this.baseStore.setState((prev) => ({
...prev,
errorMap: {
Expand All @@ -1376,7 +1361,8 @@
*/
const submitErrKey = getErrorMapKey('submit')
if (
this.state.errorMap[submitErrKey] &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.state.errorMap?.[submitErrKey] &&
cause !== 'submit' &&
!hasErrored
) {
Expand Down Expand Up @@ -1422,7 +1408,7 @@
*/
const promises: Promise<ValidationPromiseResult<TFormData>>[] = []

let fieldErrors:
let fieldErrorsFromFormValidators:
| Partial<Record<DeepKeys<TFormData>, ValidationError>>
| undefined

Expand Down Expand Up @@ -1473,26 +1459,56 @@
normalizeError<TFormData>(rawError)

if (fieldErrorsFromNormalizeError) {
fieldErrors = fieldErrors
? { ...fieldErrors, ...fieldErrorsFromNormalizeError }
fieldErrorsFromFormValidators = fieldErrorsFromFormValidators
? {

Check warning on line 1463 in packages/form-core/src/FormApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FormApi.ts#L1463

Added line #L1463 was not covered by tests
...fieldErrorsFromFormValidators,
...fieldErrorsFromNormalizeError,
}
: fieldErrorsFromNormalizeError
}
const errorMapKey = getErrorMapKey(validateObj.cause)

if (fieldErrors) {
for (const [field, fieldError] of Object.entries(fieldErrors)) {
const fieldMeta = this.getFieldMeta(field as DeepKeys<TFormData>)
if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
this.setFieldMeta(field as DeepKeys<TFormData>, (prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[errorMapKey]: fieldError,
},
}))
}
for (const field of Object.keys(
this.state.fieldMeta,
) as DeepKeys<TFormData>[]) {
const fieldMeta = this.getFieldMeta(field)
if (!fieldMeta) continue

const {
errorMap: currentErrorMap,
errorSourceMap: currentErrorMapSource,
} = fieldMeta

const newFormValidatorError = fieldErrorsFromFormValidators?.[field]

const { newErrorValue, newSource } =
determineFormLevelErrorSourceAndValue({
newFormValidatorError,
isPreviousErrorFromFormValidator:
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
currentErrorMapSource?.[errorMapKey] === 'form',
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
previousErrorValue: currentErrorMap?.[errorMapKey],
})

if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
currentErrorMap?.[errorMapKey] !== newErrorValue
) {
this.setFieldMeta(field, (prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[errorMapKey]: newErrorValue,
},
errorSourceMap: {
...prev.errorSourceMap,
[errorMapKey]: newSource,
},
}))
}
}

this.baseStore.setState((prev) => ({
...prev,
errorMap: {
Expand All @@ -1501,7 +1517,11 @@
},
}))

resolve(fieldErrors ? { fieldErrors, errorMapKey } : undefined)
resolve(
fieldErrorsFromFormValidators
? { fieldErrors: fieldErrorsFromFormValidators, errorMapKey }
: undefined,
)
}),
)
}
Expand Down
Loading