From 35d3c97de8f34bdb6b03580e3981eaa8d10cc2eb Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Wed, 2 Apr 2025 20:37:36 -0400 Subject: [PATCH] fix(form-core): fix broken sync/async validation logic This commit updates sync and async validation state to shift with FieldMeta Fixes "Cannot read properties of undefined" when removing array field #1323 --- packages/form-core/src/FieldApi.ts | 99 ++++-- packages/form-core/src/FormApi.ts | 156 +++++---- packages/form-core/src/metaHelper.ts | 1 + packages/form-core/src/types.ts | 11 + packages/form-core/src/utils.ts | 69 +++- packages/form-core/tests/FieldApi.spec.ts | 28 ++ packages/form-core/tests/FormApi.spec.ts | 382 +++++++++++++++++++++- packages/form-core/tests/utils.spec.ts | 342 ++++++++++++++++++- 8 files changed, 977 insertions(+), 111 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a95fd5515..18940ac2a 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -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, @@ -25,6 +30,7 @@ import type { ValidationCause, ValidationError, ValidationErrorMap, + ValidationErrorMapSource, } from './types' import type { AsyncValidator, SyncValidator, Updater } from './utils' @@ -561,6 +567,10 @@ export type FieldMetaBase< UnwrapFieldValidateOrFn, UnwrapFieldAsyncValidateOrFn > + /** + * @private allows tracking the source of the errors in the error map + */ + errorSourceMap: ValidationErrorMapSource /** * A flag indicating whether the field is currently being validated. */ @@ -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, ) } @@ -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 } } @@ -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 ) { @@ -1408,6 +1428,10 @@ export class FieldApi< ...prev.errorMap, [submitErrKey]: undefined, }, + errorSourceMap: { + ...prev.errorSourceMap, + [submitErrKey]: undefined, + }, })) } @@ -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) }), ) } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 6d8bf2004..eaa5544fe 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1,6 +1,7 @@ import { Derived, Store, batch } from '@tanstack/store' import { deleteBy, + determineFormLevelErrorSourceAndValue, functionalUpdate, getAsyncValidatorArray, getBy, @@ -733,22 +734,6 @@ export class FormApi< */ 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. */ @@ -1306,56 +1291,56 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - if (fieldErrors) { - for (const [field, fieldError] of Object.entries(fieldErrors) as [ - DeepKeys, - 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[]) { + 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 - >) { - 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: { @@ -1376,7 +1361,8 @@ export class FormApi< */ 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 ) { @@ -1422,7 +1408,7 @@ export class FormApi< */ const promises: Promise>[] = [] - let fieldErrors: + let fieldErrorsFromFormValidators: | Partial, ValidationError>> | undefined @@ -1473,26 +1459,56 @@ export class FormApi< normalizeError(rawError) if (fieldErrorsFromNormalizeError) { - fieldErrors = fieldErrors - ? { ...fieldErrors, ...fieldErrorsFromNormalizeError } + fieldErrorsFromFormValidators = fieldErrorsFromFormValidators + ? { + ...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) - if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) { - this.setFieldMeta(field as DeepKeys, (prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: fieldError, - }, - })) - } + for (const field of Object.keys( + this.state.fieldMeta, + ) as DeepKeys[]) { + 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: { @@ -1501,7 +1517,11 @@ export class FormApi< }, })) - resolve(fieldErrors ? { fieldErrors, errorMapKey } : undefined) + resolve( + fieldErrorsFromFormValidators + ? { fieldErrors: fieldErrorsFromFormValidators, errorMapKey } + : undefined, + ) }), ) } diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index eaa89af09..8e3e658df 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -16,6 +16,7 @@ export const defaultFieldMeta: AnyFieldMeta = { isPristine: true, errors: [], errorMap: {}, + errorSourceMap: {}, } export function metaHelper< diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index 7875a0f92..65baf0b9a 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -40,6 +40,17 @@ export type ValidationErrorMap< onServer?: TOnServerReturn } +/** + * @private allows tracking the source of the errors in the error map + */ +export type ValidationErrorMapSource = { + onMount?: ValidationSource + onChange?: ValidationSource + onBlur?: ValidationSource + onSubmit?: ValidationSource + onServer?: ValidationSource +} + /** * @private */ diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index ba15da7d5..92b9023e1 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -1,4 +1,9 @@ -import type { GlobalFormValidationError, ValidationCause } from './types' +import type { + GlobalFormValidationError, + ValidationCause, + ValidationError, + ValidationSource, +} from './types' import type { FormValidators } from './FormApi' import type { AnyFieldMeta, FieldValidators } from './FieldApi' @@ -376,3 +381,65 @@ export function shallow(objA: T, objB: T) { } return true } + +/** + * Determines the logic for determining the error source and value to set on the field meta within the form level sync/async validation. + * @private + */ +export const determineFormLevelErrorSourceAndValue = ({ + newFormValidatorError, + isPreviousErrorFromFormValidator, + previousErrorValue, +}: { + newFormValidatorError: ValidationError + isPreviousErrorFromFormValidator: boolean + previousErrorValue: ValidationError +}): { + newErrorValue: ValidationError + newSource: ValidationSource | undefined +} => { + // All falsy values are not considered errors + if (newFormValidatorError) { + return { newErrorValue: newFormValidatorError, newSource: 'form' } + } + + // Clears form level error since it's now stale + if (isPreviousErrorFromFormValidator) { + return { newErrorValue: undefined, newSource: undefined } + } + + // At this point, we have a preivous error which must have been set by the field validator, keep as is + if (previousErrorValue) { + return { newErrorValue: previousErrorValue, newSource: 'field' } + } + + // No new or previous error, clear the error + return { newErrorValue: undefined, newSource: undefined } +} + +/** + * Determines the logic for determining the error source and value to set on the field meta within the field level sync/async validation. + * @private + */ +export const determineFieldLevelErrorSourceAndValue = ({ + formLevelError, + fieldLevelError, +}: { + formLevelError: ValidationError + fieldLevelError: ValidationError +}): { + newErrorValue: ValidationError + newSource: ValidationSource | undefined +} => { + // At field level, we prioritize the field level error + if (fieldLevelError) { + return { newErrorValue: fieldLevelError, newSource: 'field' } + } + + // If there is no field level error, and there is a form level error, we set the form level error + if (formLevelError) { + return { newErrorValue: formLevelError, newSource: 'form' } + } + + return { newErrorValue: undefined, newSource: undefined } +} diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 9d03a23c8..a9fb317bf 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -62,6 +62,7 @@ describe('field api', () => { isDirty: false, errors: [], errorMap: {}, + errorSourceMap: {}, }) }) @@ -91,6 +92,7 @@ describe('field api', () => { isPristine: false, errors: [], errorMap: {}, + errorSourceMap: {}, }) }) @@ -2266,4 +2268,30 @@ describe('field api', () => { expect(firstNameField.getMeta().errors).toEqual([]) expect(lastNameField.getMeta().errors).toEqual([]) }) + + it('should update the errorSourceMap with field source when field async field error is added', async () => { + vi.useFakeTimers() + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async () => { + return 'Error' + }, + }, + }) + field.mount() + + field.setValue('test') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('field') + }) }) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 79ca9f797..e2631c436 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -588,7 +588,7 @@ describe('form api', () => { expect(field3.state.meta.errors).toStrictEqual([]) }) - it('should shift meta (nested) when removing array values', () => { + it('should shift meta (nested) when removing array values', async () => { const form = new FormApi({ defaultValues: { users: [ @@ -631,7 +631,7 @@ describe('form api', () => { expect(field2Name.state.meta.isBlurred).toBe(true) expect(field2Surname.state.meta.isBlurred).toBe(true) - form.removeFieldValue('users', 1) + await form.removeFieldValue('users', 1) expect(field0Name.state.meta.isBlurred).toBe(true) expect(field0Surname.state.meta.isBlurred).toBe(false) @@ -2695,13 +2695,6 @@ describe('form api', () => { // Verify both fields have their errors cleared expect(firstNameField.state.meta.errors).toStrictEqual([]) expect(lastNameField.state.meta.errors).toStrictEqual([]) - - // Verify previous error map still contains values for the fields as it should indicate the last error map processed for the fields - const cumulativeFieldsErrorMap = form.cumulativeFieldsErrorMap - expect(cumulativeFieldsErrorMap.firstName).toBeDefined() - expect(cumulativeFieldsErrorMap.lastName).toBeDefined() - expect(cumulativeFieldsErrorMap.firstName?.onChange).toBeUndefined() - expect(cumulativeFieldsErrorMap.lastName?.onChange).toBeUndefined() }) it('clears previous form level errors for subfields when they are no longer valid', () => { @@ -3016,3 +3009,374 @@ describe('form api', () => { }).not.toThrowError() }) }) + +it('should reset the errorSourceMap for the field when the form is reset', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: () => { + return { + fields: { name: 'Error' }, + } + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + field.setValue('hawk') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('form') + form.reset() + expect(form.getFieldMeta('name')?.errorSourceMap).toEqual({}) +}) + +it('should reset the errorSourceMap for the field when the field is reset', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChange: () => 'Error', + }, + }) + field.mount() + field.setValue('hawk') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('field') + form.resetField('name') + expect(form.getFieldMeta('name')?.errorSourceMap).toEqual({}) +}) + +it('should set the errorSourceMap undefined when form level validator is resolved', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: ({ value }) => { + return { + fields: { + name: value.name !== 'tony' ? 'Error' : null, + }, + } + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + + field.setValue('not tony') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('form') + + field.setValue('tony') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toBeUndefined() +}) + +it('should set the errorSourceMap undefined when field level validator is resolved', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChange: ({ value }) => (value !== 'tony' ? 'Error' : undefined), + }, + }) + field.mount() + + field.setValue('not tony') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('field') + + field.setValue('tony') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toBeUndefined() +}) + +it('should set errorSourceMap to form when field error is resolved but form error is not', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: ({ value }) => { + return { + fields: { + name: value.name === 'Tony' ? 'Name cannot be Tony' : null, + }, + } + }, + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChange: ({ value }) => + value === 'John' ? 'Name cannot be John' : null, + }, + }) + field.mount() + + field.setValue('John') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('field') + + field.setValue('Tony') + + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('form') +}) + +it('should prioritze field error over form error on errorSourceMap', () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: ({ value }) => { + return { + fields: { + name: value.name === 'John' ? 'Error from form' : null, + }, + } + }, + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChange: ({ value }) => (value === 'John' ? 'Error from field' : null), + }, + }) + + field.mount() + + expect(field.getMeta().errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toBeUndefined() + + field.setValue('John') + + expect(field.getMeta().errorMap.onChange).toEqual('Error from field') + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toEqual('field') + + field.setValue('Tony') + + expect(field.getMeta().errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('name')?.errorSourceMap.onChange).toBeUndefined() +}) + +it('should run form level async validation onChange and update the errorSourceMap', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value.name === 'other') + return { + fields: { + name: 'Please enter a different value', + }, + } + return null + }, + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + form.mount() + + field.mount() + + expect(form.state.errors.length).toBe(0) + field.setValue('other') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorMap).toMatchObject({ + onChange: 'Please enter a different value', + }) + + expect(field.getMeta().errorSourceMap.onChange).toEqual('form') +}) + +it('should run field level async validation onChange and update the errorSourceMap', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return null + }, + }, + }) + form.mount() + field.mount() + + field.setValue('other') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorMap).toMatchObject({ + onChange: 'Please enter a different value', + }) + + expect(field.getMeta().errorSourceMap.onChange).toEqual('field') +}) + +it('should shift sourceMap to form when async field error is resolved but async form error is not', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: ({ value }) => { + return { + fields: { name: value.name === 'John' ? 'Error from form' : null }, + } + }, + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return null + }, + }, + }) + + form.mount() + field.mount() + + field.setValue('other') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('field') + + field.setValue('John') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('form') +}) + +it('should shift sourceMap to field when async form error is resolved but async field error is not', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + validators: { + onChange: ({ value }) => { + return { + fields: { name: value.name === 'John' ? 'Error from form' : null }, + } + }, + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return null + }, + }, + }) + + form.mount() + field.mount() + + field.setValue('other') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('field') + + field.setValue('John') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('form') +}) + +it('should mark sourceMap as undefined when async field error is resolved', async () => { + vi.useFakeTimers() + const form = new FormApi({ + defaultValues: { + name: 'tony', + } as { name: string }, + }) + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return null + }, + }, + }) + + form.mount() + field.mount() + + field.setValue('other') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toEqual('field') + + field.setValue('John') + await vi.runAllTimersAsync() + + expect(field.getMeta().errorSourceMap.onChange).toBeUndefined() +}) diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index d5b55f566..dbaad093a 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { deleteBy, getBy, makePathArray, setBy } from '../src/index' +import { + deleteBy, + determineFieldLevelErrorSourceAndValue, + determineFormLevelErrorSourceAndValue, + getBy, + makePathArray, + setBy, +} from '../src/index' describe('getBy', () => { const structure = { @@ -152,3 +159,336 @@ describe('makePathArray', () => { ]) }) }) + +describe('determineFormLevelErrorSourceAndValue', () => { + describe('when a new form validator error exists', () => { + it('should return the new form error with source "form"', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: 'Form error', + isPreviousErrorFromFormValidator: false, + previousErrorValue: 'Field error', + }) + + expect(result).toEqual({ + newErrorValue: 'Form error', + newSource: 'form', + }) + }) + + it('should return the new form error even if previous error was from form', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: 'New form error', + isPreviousErrorFromFormValidator: true, + previousErrorValue: 'Old form error', + }) + + expect(result).toEqual({ + newErrorValue: 'New form error', + newSource: 'form', + }) + }) + }) + + describe('when no new form validator error exists', () => { + it('should clear the error if previous error was from form validator', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: true, + previousErrorValue: 'Old form error', + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should clear the error if new error is null and previous error was from form', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: null, + isPreviousErrorFromFormValidator: true, + previousErrorValue: 'Old form error', + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should retain field error if previous error was from field', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: false, + previousErrorValue: 'Field error', + }) + + expect(result).toEqual({ + newErrorValue: 'Field error', + newSource: 'field', + }) + }) + + it('should retain field error if new error is null and previous error was from field', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: null, + isPreviousErrorFromFormValidator: false, + previousErrorValue: 'Field error', + }) + + expect(result).toEqual({ + newErrorValue: 'Field error', + newSource: 'field', + }) + }) + + it('should handle case when previous error is null', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: false, + previousErrorValue: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should handle case when previous error is undefined', () => { + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: false, + previousErrorValue: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + }) + + describe('edge cases', () => { + it('should handle complex previous error objects', () => { + const complexError = { message: 'Complex field error', code: 456 } + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: false, + previousErrorValue: complexError, + }) + + expect(result).toEqual({ + newErrorValue: complexError, + newSource: 'field', + }) + }) + + it('should treat falsy values as not errors', () => { + // This is consistent with current behavior as of v1.1.2 + + // Test with empty string + const result1 = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: true, + previousErrorValue: '', + }) + expect(result1).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + + // Test with 0 + const result2 = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: true, + previousErrorValue: 0, + }) + expect(result2).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + + // Test with false + const result3 = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: undefined, + isPreviousErrorFromFormValidator: true, + previousErrorValue: false, + }) + expect(result3).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should prioritize form validator errors over field errors', () => { + // Note that field level validation will prioritize field errors over form errors, this function is only used for form level validation + const result = determineFormLevelErrorSourceAndValue({ + newFormValidatorError: 'Form error', + isPreviousErrorFromFormValidator: false, + previousErrorValue: 'Field error', + }) + + expect(result).toEqual({ + newErrorValue: 'Form error', + newSource: 'form', + }) + }) + }) +}) + +describe('determineFieldLevelErrorSourceAndValue', () => { + describe('when a field level error exists', () => { + it('should prioritize field error over form error', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: 'Field error', + formLevelError: 'Form error', + }) + + expect(result).toEqual({ + newErrorValue: 'Field error', + newSource: 'field', + }) + }) + + it('should return the field error when no form error exists', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: 'Field error', + formLevelError: undefined, + }) + + expect(result).toEqual({ + newErrorValue: 'Field error', + newSource: 'field', + }) + }) + + it('should handle complex field error objects', () => { + const complexError = { message: 'Complex field error', code: 123 } + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: complexError, + formLevelError: 'Form error', + }) + + expect(result).toEqual({ + newErrorValue: complexError, + newSource: 'field', + }) + }) + }) + + describe('when no field level error exists', () => { + it('should use form error when field error is undefined', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: undefined, + formLevelError: 'Form error', + }) + + expect(result).toEqual({ + newErrorValue: 'Form error', + newSource: 'form', + }) + }) + + it('should use form error when field error is null', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: null, + formLevelError: 'Form error', + }) + + expect(result).toEqual({ + newErrorValue: 'Form error', + newSource: 'form', + }) + }) + + it('should handle complex form error objects', () => { + const complexError = { message: 'Complex form error', code: 789 } + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: undefined, + formLevelError: complexError, + }) + + expect(result).toEqual({ + newErrorValue: complexError, + newSource: 'form', + }) + }) + }) + + describe('when neither field nor form level errors exist', () => { + it('should clear the error when both are undefined', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: undefined, + formLevelError: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should clear the error when both are null', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: null, + formLevelError: null, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + }) + + describe('edge cases', () => { + it('should treat empty string as not an error', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: '', + formLevelError: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should treat zero as not an error', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: 0, + formLevelError: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should treat false as not an error', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: false, + formLevelError: undefined, + }) + + expect(result).toEqual({ + newErrorValue: undefined, + newSource: undefined, + }) + }) + + it('should prioritize field level errors over form level errors', () => { + const result = determineFieldLevelErrorSourceAndValue({ + fieldLevelError: 'Field error', + formLevelError: 'Form error', + }) + + expect(result).toEqual({ + newErrorValue: 'Field error', + newSource: 'field', + }) + }) + }) +})