diff --git a/.changeset/shy-hairs-follow.md b/.changeset/shy-hairs-follow.md new file mode 100644 index 0000000000..0adaaceb15 --- /dev/null +++ b/.changeset/shy-hairs-follow.md @@ -0,0 +1,6 @@ +--- +'@tanstack/form-devtools': patch +'@tanstack/form-core': patch +--- + +Refactor: Use stable validator identity instead of index diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index f03944a444..f5ccb95d35 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1,16 +1,16 @@ import { batch, createAtom } from '@tanstack/store' import { callUpdater, createPipelineCache, evaluate, getBy } from '../utils.lib' import { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, + clearValidationSourceErrorsFromEvent, isValidationTriggerEnabled, parseValidationResult, runFieldMountValidatorPipeline, runFieldValidatorPipeline, - setIndexedError, + setValidationSourceError, } from '../validation' import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' +import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { attachWatchingListenerField, attachWatchingValidatorField, @@ -51,7 +51,12 @@ import type { ResolvedInternalFieldUpdateOptions } from '../types.lib' import type { FieldUpdateOptions, Updater } from '../types.public' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' +import type { FieldDependencyChange } from '../devtoolsBridge.lib' import type { ReadonlyAtom } from '@tanstack/store' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { ErrorVisibility, @@ -273,29 +278,18 @@ interface ListenToFieldsMeta { export type FieldWatchingFields = Map> export type FieldListenToFields = Array> - -function hasFieldValidatorErrors( - meta: InternalBaseFieldMeta, - indexes: Array, - sourceEvent: string, -): boolean { - for (const i of indexes) { - if ( - hasIndexedErrorFromSource( - meta._fieldValidatorErrors, - meta._fieldValidatorErrorSourceEvents, - i, - sourceEvent, - ) - ) { - return true - } - } - - return false -} +export type FieldWatchingValidatorFields = Map< + AnyInternalFieldApi, + Set +> export type AnyInternalFieldApi = InternalFieldApi +export type InternalFieldValidatorInstance = InternalValidatorInstance< + AnyFieldValidator, + AnyInternalFieldApi, + AnyInternalFieldApi, + AnyInternalFieldApi +> export class InternalFieldApi< TFormData, @@ -307,7 +301,13 @@ export class InternalFieldApi< _childrenMap: Map = new Map() _defaultValueCache: DefaultValueCacheEntry | null = null _atoms: FieldAtoms - _validators: Array | null + /** Stable runtime instances for this field's validator definitions. */ + _validatorInstances: InternalValidatorInstances< + AnyFieldValidator, + AnyInternalFieldApi, + AnyInternalFieldApi, + AnyInternalFieldApi + > _listeners: Array | null _errorVisibility: ErrorVisibility | undefined _errorBoundary: boolean @@ -321,11 +321,10 @@ export class InternalFieldApi< */ _watchingFields: FieldWatchingFields | null _listenToFields: FieldListenToFields | null - _watchingValidatorFields: FieldWatchingFields | null - _validateOnFields: FieldListenToFields | null - _pipelineCache: PipelineCache | null = null + _watchingValidatorFields: FieldWatchingValidatorFields | null + /** Lazily allocated runtime state for debounced field listeners. */ + _pipelineCache: PipelineCache | null = null _isKilled = false - _mountValidationRan = false _segmentValue: NameSegment /** @@ -396,14 +395,12 @@ export class InternalFieldApi< return required } - _getOrCreatePipelineCache(): PipelineCache { - if (this._isKilled) { - return createPipelineCache() - } - + /** Returns the listener runtime cache, allocating it on first use. */ + _getOrCreatePipelineCache(): PipelineCache { if (!this._pipelineCache) { this._pipelineCache = createPipelineCache() } + return this._pipelineCache } @@ -483,7 +480,7 @@ export class InternalFieldApi< this._segmentValue = segment this._parent = parent this.form = form - this._validators = + const normalizedValidators = validators && validators.length > 0 ? (validators as Array) : null @@ -494,7 +491,17 @@ export class InternalFieldApi< this._watchingFields = null this._listenToFields = null this._watchingValidatorFields = null - this._validateOnFields = null + this._validatorInstances = reconcileValidatorInstances< + AnyFieldValidator, + AnyInternalFieldApi, + AnyInternalFieldApi, + AnyInternalFieldApi + >({ + definitions: normalizedValidators, + instances: null, + owner: this, + scope: 'field', + }) const reconciledListeners = reconcileWatchedListenerFields({ field: this, @@ -509,14 +516,11 @@ export class InternalFieldApi< const reconciledValidators = reconcileWatchedValidatorFields({ field: this, - prevListenToFields: this._validateOnFields, - nextValidators: validators as Array | undefined, + validatorInstances: this._validatorInstances, form, }) reconciledValidators.attach.forEach(attachWatchingValidatorField) - this._validators = reconciledValidators.items - this._validateOnFields = reconciledValidators.listenToFields } _update(options: Omit) { @@ -540,15 +544,48 @@ export class InternalFieldApi< this._listeners = reconciledListeners.items this._listenToFields = reconciledListeners.listenToFields const notifyDependencyChanges = devtools().fieldDependenciesChanged - const dependencyChanges = notifyDependencyChanges - ? [...reconciledListeners.attach, ...reconciledListeners.detach] - : null + const dependencyChanges: Array | null = + notifyDependencyChanges + ? [...reconciledListeners.attach, ...reconciledListeners.detach] + : null if (options.validators) { + const previousValidators = this._validatorInstances?.map( + (instance) => instance.definition, + ) + const nextValidators = + options.validators.length > 0 + ? (options.validators as Array) + : null + this._validatorInstances = reconcileValidatorInstances< + AnyFieldValidator, + AnyInternalFieldApi, + AnyInternalFieldApi, + AnyInternalFieldApi + >({ + definitions: nextValidators, + previousDefinitions: previousValidators ?? null, + instances: this._validatorInstances, + owner: this, + scope: 'field', + onBeforeDispose: (validatorInstance) => { + validatorInstance.resolvedWatchFields?.forEach((sourceField) => { + const operation = { + kind: 'validator' as const, + sourceField, + watchingField: this, + validatorInstance, + } + detachWatchingValidatorField(operation) + dependencyChanges?.push(operation) + }) + validatorInstance.resolvedWatchFields = null + this._removeValidatorInstance(validatorInstance) + }, + }) const reconciledValidators = reconcileWatchedValidatorFields({ field: this, - prevListenToFields: this._validateOnFields, - nextValidators: options.validators as Array, + validatorInstances: this._validatorInstances, form: this.form, }) @@ -557,8 +594,6 @@ export class InternalFieldApi< ) reconciledValidators.attach.forEach(attachWatchingValidatorField) - this._validators = reconciledValidators.items - this._validateOnFields = reconciledValidators.listenToFields dependencyChanges?.push( ...reconciledValidators.attach, ...reconciledValidators.detach, @@ -679,7 +714,7 @@ export class InternalFieldApi< event: 'change' | 'blur' | 'submit', options?: { onResult?: boolean - onlyRunValidatorIndeces?: Array | null + onlyRunValidatorInstances?: ReadonlySet | null _startValidation?: () => () => void }, ): Promise { @@ -690,7 +725,7 @@ export class InternalFieldApi< thrownError: null, } - const validators = this._validators + const validators = this._validatorInstances if (!validators) return { @@ -720,7 +755,7 @@ export class InternalFieldApi< options?.onResult !== false ? (result) => this._processValidationResult(result, event) : undefined, - validatorIndecesToRun: options?.onlyRunValidatorIndeces ?? null, + validatorInstancesToRun: options?.onlyRunValidatorInstances ?? null, }) } finally { finishValidation() @@ -752,75 +787,102 @@ export class InternalFieldApi< sourceEvent: string, ) { if (this._isKilled) return + const validatorInstance = + result.validatorInstance as InternalFieldValidatorInstance this._setMeta((prev) => { const { self } = parseValidationResult(result.result) - const nextErrors = setIndexedError( - prev._fieldValidatorErrors, - prev._fieldValidatorErrorSourceEvents, - result.validatorIndex, + const nextErrors = setValidationSourceError( + prev._validationSourceErrors, + validatorInstance, self ?? [], sourceEvent, ) if (!nextErrors) return prev + if (self && self.length > 0) { + validatorInstance.addErrorTarget(this) + } else { + validatorInstance.deleteErrorTarget(this) + } + return { ...prev, - _fieldValidatorErrors: nextErrors.errors, - _fieldValidatorErrorSourceEvents: nextErrors.errorSourceEvents, + _validationSourceErrors: nextErrors.errorMap, } satisfies InternalBaseFieldMeta }) } + /** Removes all field-owned state associated with a disposed validator. */ + _removeValidatorInstance( + validatorInstance: InternalFieldValidatorInstance, + ): void { + this._setMeta((prev) => { + const nextErrors = setValidationSourceError( + prev._validationSourceErrors, + validatorInstance, + [], + '', + ) + if (!nextErrors) return prev + + return { + ...prev, + _validationSourceErrors: nextErrors.errorMap, + } + }) + validatorInstance.deleteErrorTarget(this) + } + _clearEventErrors(event: 'change' | 'blur', sourceEvent: string): void { if (this._isKilled) return - const validators = this._validators + const validators = this._validatorInstances if (!validators) return - const eventErrorIndexes: Array = [] - - for (let i = 0; i < validators.length; i++) { - const runsOnEvent = validators[i]!.triggers.some((trigger) => - isValidationTriggerEnabled(trigger, { - scope: 'field', - event, - fieldApi: this, - formApi: this.form, - }), + const validatorInstancesToClear: Array = [] + + for (const validatorInstance of validators) { + const runsOnEvent = validatorInstance.definition.triggers.some( + (trigger) => + isValidationTriggerEnabled(trigger, { + scope: 'field', + event, + fieldApi: this, + formApi: this.form, + }), ) if (!runsOnEvent) { - eventErrorIndexes.push(i) + validatorInstancesToClear.push(validatorInstance) } } - if (eventErrorIndexes.length === 0) return - if ( - !hasFieldValidatorErrors( - this._getBaseMeta(), - eventErrorIndexes, - sourceEvent, - ) - ) { - return - } + if (validatorInstancesToClear.length === 0) return + const clearedInstances = validatorInstancesToClear.filter( + (validatorInstance) => + this._getBaseMeta()._validationSourceErrors?.get(validatorInstance) + ?.sourceEvent === sourceEvent, + ) + if (clearedInstances.length === 0) return this._setMeta((prev) => { - const clearedErrors = clearIndexedErrorsFromSource( - prev._fieldValidatorErrors, - prev._fieldValidatorErrorSourceEvents, - eventErrorIndexes, + const clearedErrors = clearValidationSourceErrorsFromEvent( + prev._validationSourceErrors, + clearedInstances, sourceEvent, ) if (!clearedErrors) return prev + clearedInstances.forEach((validatorInstance) => + validatorInstance.deleteErrorTarget(this), + ) + return { ...prev, - _fieldValidatorErrors: clearedErrors.errors, - _fieldValidatorErrorSourceEvents: clearedErrors.errorSourceEvents, + _validationSourceErrors: clearedErrors.errorMap, } satisfies InternalBaseFieldMeta }) this._pruneIfUnused() @@ -939,7 +1001,7 @@ export class InternalFieldApi< _notifyValidator( trigger: 'change' | 'blur' | 'submit', seenFields: WeakSet, - onlyRunValidatorIndeces: Array | null = null, + onlyRunValidatorInstances: ReadonlySet | null = null, ) { if (this._isKilled) return @@ -952,20 +1014,20 @@ export class InternalFieldApi< seenFields.add(this) - if (this._validators && onlyRunValidatorIndeces) { - this._runFieldValidation(trigger, { onlyRunValidatorIndeces }) + if (this._validatorInstances && onlyRunValidatorInstances) { + this._runFieldValidation(trigger, { onlyRunValidatorInstances }) } const watchingFields = this._watchingValidatorFields if (!watchingFields) return - for (const [watchingField, validatorIndeces] of watchingFields) { + for (const [watchingField, validatorInstances] of watchingFields) { if (watchingField._isKilled) { watchingFields.delete(watchingField) continue } - watchingField._notifyValidator(trigger, seenFields, [...validatorIndeces]) + watchingField._notifyValidator(trigger, seenFields, validatorInstances) } if (watchingFields.size === 0) { @@ -984,7 +1046,11 @@ export class InternalFieldApi< } const isMountTransition = this._refCount === 0 - const isFirstMount = isMountTransition && !this._mountValidationRan + const mountValidatorInstances = isMountTransition + ? (this._validatorInstances?.filter( + (validatorInstance) => !validatorInstance.didRunOnMount, + ) ?? []) + : [] this._refCount++ this._getOrCreateAtoms() if (isMountTransition) { @@ -993,9 +1059,11 @@ export class InternalFieldApi< this._notifyListener('mount', new WeakSet()) - if (isFirstMount) { - this._mountValidationRan = true - this._runMountValidation() + if (mountValidatorInstances.length > 0) { + mountValidatorInstances.forEach((validatorInstance) => + validatorInstance.markMountValidationRan(), + ) + this._runMountValidation(mountValidatorInstances) } return () => this._unregister() @@ -1005,8 +1073,7 @@ export class InternalFieldApi< * @private * Runs validators marked with runOnMount on the first component mount. */ - _runMountValidation(): void { - const validators = this._validators + _runMountValidation(validators = this._validatorInstances): void { if (!validators || validators.length === 0) return this._setValidationCount((count) => count + 1) diff --git a/packages/form-core/src/FieldApi/fieldState.lib.ts b/packages/form-core/src/FieldApi/fieldState.lib.ts index 2ea19099e4..2d0940b24b 100644 --- a/packages/form-core/src/FieldApi/fieldState.lib.ts +++ b/packages/form-core/src/FieldApi/fieldState.lib.ts @@ -1,4 +1,5 @@ import { createFormStateProxy } from '../FormApi/formState.lib' +import { getValidationSourceErrors } from '../validation' import type { RootCounterContributionKey } from './RootFieldApi.lib' import type { AnyInternalFieldApi } from './FieldApi.lib' import type { @@ -12,6 +13,7 @@ import type { ErrorVisibilityFieldState, ValidationIssue, } from '../validation.public' +import type { ValidationSourceErrorMap } from '../validation' import type { Atom, ReadonlyAtom } from '@tanstack/store' export type ChildContributionKey = @@ -27,11 +29,13 @@ export const childContributionKeys: Array = [ ] interface MetaExtension { - _formValidatorErrors: Array> - _formValidatorErrorSourceEvents: Array - _formGroupValidatorErrors: FormGroupFieldErrorMeta | null - _fieldValidatorErrors: Array> - _fieldValidatorErrorSourceEvents: Array + /** + * Validation errors targeting this field, keyed by stable source instance. + * + * Derived errors are ordered by field, group, and form scope, then by each + * scope's validator pipeline order rather than this map's insertion order. + */ + _validationSourceErrors: ValidationSourceErrorMap | null childContributionCounts: ChildContributionCounts _validationCount: number /** @@ -41,11 +45,6 @@ interface MetaExtension { _arrayVersion: number } -export interface FormGroupFieldErrorMeta { - errors: Array> - errorSourceEvents: Array -} - export interface InternalBaseFieldMeta extends BaseFieldMeta, MetaExtension {} export interface InternalFieldMeta extends AnyPublicFieldMeta, MetaExtension {} @@ -83,11 +82,7 @@ export const defaultInternalBaseFieldMeta: InternalBaseFieldMeta = { validating: 0, }, _validationCount: 0, - _fieldValidatorErrors: [], - _fieldValidatorErrorSourceEvents: [], - _formValidatorErrors: [], - _formValidatorErrorSourceEvents: [], - _formGroupValidatorErrors: null, + _validationSourceErrors: null, _arrayVersion: 0, } @@ -295,10 +290,10 @@ export function getChildContributionStates( } } -function hasValidatorErrors( - errors: Array> | undefined, +function hasValidationSourceErrors( + errors: ValidationSourceErrorMap | null, ): boolean { - return errors?.some((validatorErrors) => validatorErrors.length > 0) ?? false + return errors !== null && errors.size > 0 } export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { @@ -308,9 +303,7 @@ export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { if (meta.isValidating) return false if (meta._validationCount !== 0) return false if (meta._arrayVersion !== 0) return false - if (hasValidatorErrors(meta._fieldValidatorErrors)) return false - if (hasValidatorErrors(meta._formGroupValidatorErrors?.errors)) return false - if (hasValidatorErrors(meta._formValidatorErrors)) return false + if (hasValidationSourceErrors(meta._validationSourceErrors)) return false return childContributionKeys.every( (key) => meta.childContributionCounts[key] === 0, @@ -323,26 +316,18 @@ function getErrorsFromBaseMeta( ): Array { let result: Array if ( - previousMeta?._fieldValidatorErrors === baseMeta._fieldValidatorErrors && - previousMeta._formGroupValidatorErrors === - baseMeta._formGroupValidatorErrors && - previousMeta._formValidatorErrors === baseMeta._formValidatorErrors + previousMeta?._validationSourceErrors === baseMeta._validationSourceErrors ) { result = previousMeta.original.errors } else { - result = baseMeta._fieldValidatorErrors - .concat(baseMeta._formGroupValidatorErrors?.errors ?? []) - .concat(baseMeta._formValidatorErrors) - // ValidationError is OneOrMany, TypeScript doesn't realize that - // flat also takes care of that - .flat() + result = getValidationSourceErrors(baseMeta._validationSourceErrors) } return result } export function hasFieldMetaErrors(meta: InternalBaseFieldMeta): boolean { return ( - getErrorsFromBaseMeta(meta).length > 0 || + hasValidationSourceErrors(meta._validationSourceErrors) || meta.childContributionCounts.error > 0 ) } diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index d30fdbc53d..322605a645 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -22,6 +22,7 @@ import type { AnyInternalFieldApi, FieldListenToFields, FieldWatchingFields, + FieldWatchingValidatorFields, } from './FieldApi.lib' import type { InternalRootFieldApi, @@ -31,7 +32,7 @@ import type { ChildContributionStates } from './fieldState.lib' import type { NameSegment } from '../utils.lib' type DetachWatchingFieldFn = ( - operation: FieldDependencyChange, + operation: Extract, options?: { pruneSourceField?: boolean }, ) => void @@ -87,7 +88,12 @@ function detachOutgoingWatchedFields({ }) { listenToFields?.forEach((sourceMetas, watcherIndex) => { for (const { field: sourceField } of sourceMetas) { - const change = { sourceField, watchingField: field, watcherIndex } + const change = { + kind: 'listener' as const, + sourceField, + watchingField: field, + watcherIndex, + } detach(change, { pruneSourceField: false }) dependencyChanges?.push(change) @@ -126,7 +132,12 @@ function detachIncomingWatchedFields({ for (const [watchingField, watcherIndexes] of Array.from(watchingFields)) { for (const watcherIndex of Array.from(watcherIndexes)) { - const change = { sourceField, watchingField, watcherIndex } + const change = { + kind: 'listener' as const, + sourceField, + watchingField, + watcherIndex, + } detach(change, { pruneSourceField: false }) dependencyChanges?.push(change) setListenToFields( @@ -144,6 +155,94 @@ function detachIncomingWatchedFields({ } } +/** + * Detaches watched-field dependencies owned by validators on a field being killed. + * + * Validator instances hold the forward references in `resolvedWatchFields`, + * while each watched source field holds the reverse registration in + * `_watchingValidatorFields`. The reverse registrations must be removed before + * the instances are disposed. Surviving source fields are queued for pruning + * after the complete kill pass to avoid recursively mutating the field tree + * during cleanup. + */ +function detachWatchedValidatorFields({ + field, + nodesToKill, + fieldsToPruneAfterKill, + dependencyChanges, +}: { + field: AnyInternalFieldApi + nodesToKill: Set + fieldsToPruneAfterKill: Set + dependencyChanges: Array | null +}) { + field._validatorInstances?.forEach((validatorInstance) => { + validatorInstance.resolvedWatchFields?.forEach((sourceField) => { + const change = { + kind: 'validator' as const, + sourceField, + watchingField: field, + validatorInstance, + } + detachWatchingValidatorField(change, { pruneSourceField: false }) + dependencyChanges?.push(change) + + if (!nodesToKill.has(sourceField)) { + fieldsToPruneAfterKill.add(sourceField) + } + }) + validatorInstance.resolvedWatchFields = null + }) +} + +/** + * Detaches validators on other fields that watch a source field being killed. + * + * This removes the source field's reverse registrations and the corresponding + * forward references from each surviving validator instance. Surviving + * watching fields are queued for pruning after the complete kill pass. + */ +function detachWatchingValidatorFields({ + sourceField, + watchingFields, + nodesToKill, + fieldsToPruneAfterKill, + dependencyChanges, +}: { + sourceField: AnyInternalFieldApi + watchingFields: FieldWatchingValidatorFields | null + nodesToKill: Set + fieldsToPruneAfterKill: Set + dependencyChanges: Array | null +}) { + if (!watchingFields) return + + for (const [watchingField, validatorInstances] of Array.from( + watchingFields, + )) { + for (const validatorInstance of Array.from(validatorInstances)) { + const change = { + kind: 'validator' as const, + sourceField, + watchingField, + validatorInstance, + } + detachWatchingValidatorField(change, { pruneSourceField: false }) + dependencyChanges?.push(change) + + validatorInstance.resolvedWatchFields?.forEach((resolvedField, name) => { + if (resolvedField === sourceField) { + validatorInstance.deleteResolvedWatchField(name) + } + }) + + if (!nodesToKill.has(watchingField)) { + fieldsToPruneAfterKill.add(watchingField) + } + } + } +} + function detachLinkedFieldReferences({ field, nodesToKill, @@ -165,15 +264,12 @@ function detachLinkedFieldReferences({ }) field._listenToFields = null - detachOutgoingWatchedFields({ + detachWatchedValidatorFields({ field, - listenToFields: field._validateOnFields, - detach: detachWatchingValidatorField, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, }) - field._validateOnFields = null detachIncomingWatchedFields({ sourceField: field, @@ -189,17 +285,12 @@ function detachLinkedFieldReferences({ }) field._watchingFields = null - detachIncomingWatchedFields({ + detachWatchingValidatorFields({ sourceField: field, watchingFields: field._watchingValidatorFields, - detach: detachWatchingValidatorField, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, - getListenToFields: (watchingField) => watchingField._validateOnFields, - setListenToFields: (watchingField, listenToFields) => { - watchingField._validateOnFields = listenToFields - }, }) field._watchingValidatorFields = null } @@ -340,6 +431,9 @@ export function killField( for (const node of nodesToKill) { const nodeMeta = node._atoms.meta?.get() + nodeMeta?._validationSourceErrors?.forEach((_error, validationSource) => + validationSource.deleteErrorTarget(node), + ) detachLinkedFieldReferences({ field: node, nodesToKill: nodesToKillSet, @@ -375,6 +469,8 @@ export function killField( cancelPipelineCache(node._pipelineCache) node._pipelineCache = null } + node._validatorInstances?.forEach((instance) => instance.dispose()) + node._validatorInstances = null node._childrenMap.clear() node._parent._removeChild(node._segment) } @@ -405,35 +501,6 @@ export function killField( return prev }) - - field.form._atoms.meta.fieldErrors.set((prev) => { - const fieldErrors = [...prev] - let changed = false - - for (let i = 0; i < fieldErrors.length; i++) { - const currFieldErrors = fieldErrors[i] - if (!currFieldErrors || currFieldErrors.size === 0) continue - - let next: Set | undefined - - for (const node of currFieldErrors) { - if (nodesToKillSet.has(node)) { - if (!next) { - next = new Set(currFieldErrors) - } - - next.delete(node) - } - } - - if (next) { - fieldErrors[i] = next - changed = true - } - } - - return changed ? fieldErrors : prev - }) }) if (removedFields.length > 0) { @@ -456,7 +523,9 @@ export function canPruneField(field: AnyInternalFieldApi): boolean { // Watched source maps retain and notify this field, so keep both endpoints // reachable from the form trie while an outgoing link is active. if (field._listenToFields) return false - if (field._validateOnFields) return false + if (field._validatorInstances?.some((v) => v.resolvedWatchFields)) { + return false + } const meta = field._atoms.meta?.get() ?? defaultInternalBaseFieldMeta if (!isPrunableMeta(meta)) return false @@ -500,7 +569,7 @@ export function touchAllFieldsAndCollectSubmitValidators( 'submit', ) - if (field._validators && field._validators.length > 0) { + if (field._validatorInstances && field._validatorInstances.length > 0) { fieldsWithValidators.push(field) } }) diff --git a/packages/form-core/src/FieldApi/linked-fields.lib.ts b/packages/form-core/src/FieldApi/linked-fields.lib.ts index e26ea3cf45..d548ac1f29 100644 --- a/packages/form-core/src/FieldApi/linked-fields.lib.ts +++ b/packages/form-core/src/FieldApi/linked-fields.lib.ts @@ -1,7 +1,7 @@ import type { - AnyFieldValidator, AnyInternalFieldApi, FieldListenToFields, + InternalFieldValidatorInstance, } from './FieldApi.lib' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' import type { AnyFieldListener } from '../listeners.public' @@ -14,11 +14,19 @@ interface ListenToFieldsMeta { } interface WatchFieldOperation { + kind: 'listener' sourceField: AnyInternalFieldApi watchingField: AnyInternalFieldApi watcherIndex: WatcherIndex } +export interface ValidatorWatchFieldOperation { + kind: 'validator' + sourceField: AnyInternalFieldApi + watchingField: AnyInternalFieldApi + validatorInstance: InternalFieldValidatorInstance +} + export interface DetachWatchingFieldOptions { pruneSourceField?: boolean } @@ -83,6 +91,7 @@ function reconcileWatchedFields }>({ // Field reference and name are mismatched, so detach to reattach to actual if (prevMeta) { detach.push({ + kind: 'listener', sourceField: prevMeta.field, watchingField: field, watcherIndex, @@ -91,6 +100,7 @@ function reconcileWatchedFields }>({ } attach.push({ + kind: 'listener', sourceField, watchingField: field, watcherIndex, @@ -102,6 +112,7 @@ function reconcileWatchedFields }>({ for (const [key, prevMeta] of prevByKey.entries()) { detach.push({ + kind: 'listener', sourceField: prevMeta.field, watchingField: field, watcherIndex: ofWatcherKey(key)[0], @@ -136,22 +147,64 @@ export function reconcileWatchedListenerFields({ } export function reconcileWatchedValidatorFields({ - nextValidators, - prevListenToFields, + validatorInstances, field, form, }: { - nextValidators: Array | null | undefined - prevListenToFields: FieldListenToFields | null + validatorInstances: ReadonlyArray | null field: AnyInternalFieldApi form: AnyInternalFormApi -}): ReconciledWatchedFields { - return reconcileWatchedFields({ - nextItems: nextValidators, - prevListenToFields, - field, - form, +}): { + attach: Array + detach: Array +} { + const attach: Array = [] + const detach: Array = [] + + validatorInstances?.forEach((validatorInstance) => { + const previous = validatorInstance.resolvedWatchFields + const next = new Map() + const names = [...new Set(validatorInstance.definition.watchFields ?? [])] + + for (const name of names) { + const sourceField = form._getOrCreateFieldApi({ name }) + next.set(name, sourceField) + + const previousField = previous?.get(name) + if (previousField === sourceField) continue + + if (previousField) { + detach.push({ + kind: 'validator', + sourceField: previousField, + watchingField: field, + validatorInstance, + }) + } + + attach.push({ + kind: 'validator', + sourceField, + watchingField: field, + validatorInstance, + }) + } + + previous?.forEach((sourceField, name) => { + if (next.has(name)) return + + detach.push({ + kind: 'validator', + sourceField, + watchingField: field, + validatorInstance, + }) + }) + + validatorInstance.resolvedWatchFields = next.size > 0 ? next : null }) + + return { attach, detach } } function attachWatchingField( @@ -232,26 +285,49 @@ export function detachWatchingListenerField( ) } -export function attachWatchingValidatorField(operation: WatchFieldOperation) { - attachWatchingField( - (source) => source._watchingValidatorFields, - (source, watchingFields) => { - source._watchingValidatorFields = watchingFields - }, - operation, - ) +export function attachWatchingValidatorField({ + sourceField, + watchingField, + validatorInstance, +}: ValidatorWatchFieldOperation) { + let watchingFields = sourceField._watchingValidatorFields + if (!watchingFields) { + watchingFields = new Map() + sourceField._watchingValidatorFields = watchingFields + } + + let instances = watchingFields.get(watchingField) + if (!instances) { + instances = new Set() + watchingFields.set(watchingField, instances) + } + + instances.add(validatorInstance) } export function detachWatchingValidatorField( - operation: WatchFieldOperation, + { + sourceField, + watchingField, + validatorInstance, + }: ValidatorWatchFieldOperation, options?: DetachWatchingFieldOptions, ) { - detachWatchingField( - (source) => source._watchingValidatorFields, - (source) => { - source._watchingValidatorFields = null - }, - operation, - options, - ) + const watchingFields = sourceField._watchingValidatorFields + if (!watchingFields) return + + const instances = watchingFields.get(watchingField) + if (!instances) return + + instances.delete(validatorInstance) + if (instances.size === 0) { + watchingFields.delete(watchingField) + if (watchingFields.size === 0) { + sourceField._watchingValidatorFields = null + } + } + + if (options?.pruneSourceField !== false) { + sourceField._pruneIfUnused() + } } diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 679ef49276..54d2cb04d8 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -25,22 +25,24 @@ import { } from '../FieldApi/fieldTraversal.lib' import { defaultInternalBaseFieldMeta } from '../FieldApi/fieldState.lib' import { - clearIndexedErrorsFromSource, + clearValidationSourceErrorsFromEvent, isErrorResult, isValidationTriggerEnabled, parseValidationResult, reconcileRoutedFieldErrors, runFormMountValidatorPipeline, runFormValidatorPipeline, - setIndexedError, + setValidationSourceError, } from '../validation' import { runFormListenerPipeline } from '../listeners.lib' import { applyServerState } from '../ssr.lib' import { devtools } from '../devtoolsBridge.lib' +import { reconcileValidatorInstances } from '../ValidatorInstance.lib' +import { InternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import { runSubmissionProcess } from './handleSubmit.lib' import { ArrayMethods } from './array-methods.lib' import { - clearFormValidatorErrorsFromSource, + clearFormValidationSourceErrorsFromEvent, compareFormStateSnapshots, getFormStateSnapshot, reconcileFormErrorFields, @@ -74,6 +76,7 @@ import type { Updater } from '../types.public' import type { FormValidateResult, FormValidationError, + FormValidator, FormValidators, ToFormErrorTypes, ValidationIssue, @@ -81,6 +84,11 @@ import type { } from '../validation.public' import type { FormListenerTriggers } from '../listeners.public' import type { ServerFormState } from '../ssr.public' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' +import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' export interface FormMetaAtoms { isDirty: Atom @@ -90,12 +98,6 @@ export interface FormMetaAtoms { */ touchedFieldCount: Atom formErrors: Atom - /** - * @private - * Dense array of field references per validator index that have errors. - * Used to clear stale field errors when a validator no longer reports them. - */ - fieldErrors: Atom>> /** * @private * Root fields whose own or descendant meta currently contributes errors. @@ -119,24 +121,17 @@ export interface FormAtoms { defaultValuesVersion: Atom } -function createInitialFormErrorMeta(validatorCount: number): FormErrorMeta { +function createInitialFormErrorMeta(): FormErrorMeta { return { - errors: Array.from({ length: validatorCount }, () => []), - errorSourceEvents: Array.from({ length: validatorCount }, () => null), + validationSourceErrors: null, } } -function createInitialFormMetaAtoms(validatorCount: number): FormMetaAtoms { +function createInitialFormMetaAtoms(): FormMetaAtoms { return { isDirty: createAtom(false), touchedFieldCount: createAtom(0), - formErrors: createAtom(createInitialFormErrorMeta(validatorCount)), - fieldErrors: createAtom( - Array.from( - { length: validatorCount }, - () => new Set(), - ), - ), + formErrors: createAtom(createInitialFormErrorMeta()), errorFields: createAtom(new Set()), fieldValidationCount: createAtom(0), validationCount: createAtom(0), @@ -147,6 +142,11 @@ function createInitialFormMetaAtoms(validatorCount: number): FormMetaAtoms { } export type AnyInternalFormApi = InternalFormApi +export type InternalFormValidatorInstance = InternalValidatorInstance< + FormValidator, + AnyInternalFormApi, + AnyInternalFieldApi +> type InternalFormOptions< TFormData, @@ -217,9 +217,19 @@ export class InternalFormApi< _fieldRootNode: InternalRootFieldApi _defaultValueCache: DefaultValueCacheEntry | null = null _options: InternalFormOptions + /** Stable runtime instances correlated with `_options.validators` by slot. */ + _validatorInstances: InternalValidatorInstances< + TFormValidators[number], + AnyInternalFormApi, + AnyInternalFieldApi + > + /** Stable source for errors returned directly by the form's `onSubmit`. */ + _onSubmitSource: InternalValidationSourceInstance< + AnyInternalFormApi, + AnyInternalFieldApi + > _lastUpdateDefaultValues: TFormData - _pipelineCache: PipelineCache - _schemaOutputs: Array = [] + _pipelineCache: PipelineCache _lastServerState: ServerFormState | null = null get state(): FormState< @@ -285,18 +295,33 @@ export class InternalFormApi< this._options = { ...options, formId: options.formId ?? uuid() } this._lastUpdateDefaultValues = options.defaultValues this._pipelineCache = createPipelineCache() - const validatorCount = this._options.validators?.length ?? 0 this._atoms = { values: createAtom(options.defaultValues), - meta: createInitialFormMetaAtoms(validatorCount), + meta: createInitialFormMetaAtoms(), resetVersion: createAtom(0), defaultValuesVersion: createAtom(0), } this._fieldRootNode = new InternalRootFieldApi(this) + this._onSubmitSource = new InternalValidationSourceInstance({ + owner: this, + scope: 'onSubmit', + }) this.atom = createAtom(() => getFormStateSnapshot(this), { compare: compareFormStateSnapshots, }) + this._validatorInstances = reconcileValidatorInstances< + TFormValidators[number], + AnyInternalFormApi, + AnyInternalFieldApi + >({ + definitions: this._options.validators, + instances: null, + owner: this, + scope: 'form', + onBeforeDispose: (validatorInstance) => + this._removeValidatorInstance(validatorInstance), + }) applyServerState( this, @@ -320,28 +345,16 @@ export class InternalFormApi< } _clearFormValidationSource(sourceEvent: string): void { - const formErrors = this._atoms.meta.formErrors.get() - const fieldErrors = this._atoms.meta.fieldErrors.get() - const eventErrorCount = Math.max( - formErrors.errors.length, - fieldErrors.length, - ) - if (eventErrorCount === 0) return + const validationSources = this._validatorInstances ?? [] - const eventErrorIndexes = Array.from( - { length: eventErrorCount }, - (_, index) => index, - ) - - clearFormValidatorErrorsFromSource({ + clearFormValidationSourceErrorsFromEvent({ formErrors: this._atoms.meta.formErrors, - fieldErrors: this._atoms.meta.fieldErrors, errorFields: this._atoms.meta.errorFields, - indexes: eventErrorIndexes, + validationSources, sourceEvent, fieldScope: { type: 'all' }, - clearFieldEventErrors: (field, indexes, eventSource) => - this._clearFieldEventErrors(field, indexes, eventSource), + clearFieldEventErrors: (field, instances, eventSource) => + this._clearFieldEventErrors(field, instances, eventSource), reconcileErrorFields: true, }) } @@ -356,7 +369,8 @@ export class InternalFormApi< cancelPipelineCache(this._pipelineCache) this._pipelineCache = createPipelineCache() - this._schemaOutputs = [] + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) + this._onSubmitSource.resetRuntime() this._defaultValueCache = null batch(() => { @@ -367,18 +381,9 @@ export class InternalFormApi< this._fieldRootNode._children.forEach((child) => child._kill({ listenerEvent: 'reset' }), ) - const validatorCount = this._options.validators?.length ?? 0 this._atoms.meta.isDirty.set(false) this._atoms.meta.touchedFieldCount.set(0) - this._atoms.meta.formErrors.set( - createInitialFormErrorMeta(validatorCount), - ) - this._atoms.meta.fieldErrors.set( - Array.from( - { length: validatorCount }, - () => new Set(), - ), - ) + this._atoms.meta.formErrors.set(createInitialFormErrorMeta()) this._atoms.meta.errorFields.set(new Set()) this._atoms.meta.fieldValidationCount.set(0) this._atoms.meta.validationCount.set(0) @@ -408,13 +413,19 @@ export class InternalFormApi< formId: options.formId ?? oldOptions.formId, } - if ( - (options.validators?.length ?? 0) !== (oldOptions.validators?.length ?? 0) - ) { - console.warn( - 'TanStack Form: The length of the validator array should not change after initialization', - ) - } + this._validatorInstances = reconcileValidatorInstances< + TFormValidators[number], + AnyInternalFormApi, + AnyInternalFieldApi + >({ + definitions: this._options.validators, + previousDefinitions: oldOptions.validators ?? null, + instances: this._validatorInstances, + owner: this, + scope: 'form', + onBeforeDispose: (validatorInstance) => + this._removeValidatorInstance(validatorInstance), + }) if (didDefaultValuesChange) { batch(() => { @@ -489,6 +500,9 @@ export class InternalFormApi< cancelPipelineCache(current._pipelineCache) current._pipelineCache = null } + current._validatorInstances?.forEach((instance) => + instance.resetRuntime(), + ) current._setMeta(() => defaultInternalBaseFieldMeta) } }) @@ -637,21 +651,10 @@ export class InternalFormApi< sourceEvent: string, event: Exclude, ) { - const validatorCount = this._options.validators?.length ?? 0 - const formErrors = this._atoms.meta.formErrors.get() - const fieldErrors = this._atoms.meta.fieldErrors.get() - const fieldFormErrorCount = - field?._getBaseMeta()._formValidatorErrors.length ?? 0 - const eventErrorCount = Math.max( - formErrors.errors.length, - fieldErrors.length, - fieldFormErrorCount, - ) - const eventErrorIndexes: Array = [] + const validatorInstancesToClear: Array = [] - for (let i = 0; i < validatorCount; i++) { - const validator = this._options.validators?.[i] - if (!validator) continue + for (const validatorInstance of this._validatorInstances ?? []) { + const validator = validatorInstance.definition const runsOnEvent = validator.triggers.some((trigger) => isValidationTriggerEnabled(trigger, { @@ -663,23 +666,36 @@ export class InternalFormApi< ) if (!runsOnEvent) { - eventErrorIndexes.push(i) + validatorInstancesToClear.push(validatorInstance) } } - for (let i = validatorCount; i < eventErrorCount; i++) { - eventErrorIndexes.push(i) + if (sourceEvent === 'submit') { + this._clearSubmitErrors(field) } - clearFormValidatorErrorsFromSource({ + clearFormValidationSourceErrorsFromEvent({ formErrors: this._atoms.meta.formErrors, - fieldErrors: this._atoms.meta.fieldErrors, errorFields: this._atoms.meta.errorFields, - indexes: eventErrorIndexes, + validationSources: validatorInstancesToClear, sourceEvent, fieldScope: field ? { type: 'field', field } : { type: 'none' }, - clearFieldEventErrors: (targetField, indexes, eventSource) => - this._clearFieldEventErrors(targetField, indexes, eventSource), + clearFieldEventErrors: (targetField, instances, eventSource) => + this._clearFieldEventErrors(targetField, instances, eventSource), + }) + } + + _clearSubmitErrors(field: AnyInternalFieldApi | null): void { + batch(() => { + this._setFormValidationSourceError(this._onSubmitSource, [], '') + + if (!field || !this._onSubmitSource.errorTargets?.has(field)) return + + this._clearFieldValidationSourceError(field, this._onSubmitSource) + this._atoms.meta.errorFields.set((prev) => + reconcileFormErrorFields(prev, [field]), + ) + field._pruneIfUnused() }) } @@ -750,16 +766,15 @@ export class InternalFormApi< return resolvedFieldErrors } - _setFormValidatorError( - validatorIndex: number, + _setFormValidationSourceError( + validationSource: AnyInternalValidationSourceInstance, errors: Array, sourceEvent: string, ) { this._atoms.meta.formErrors.set((prev) => { - const nextErrors = setIndexedError( - prev.errors, - prev.errorSourceEvents, - validatorIndex, + const nextErrors = setValidationSourceError( + prev.validationSourceErrors, + validationSource, errors, sourceEvent, ) @@ -768,23 +783,21 @@ export class InternalFormApi< return { ...prev, - errors: nextErrors.errors, - errorSourceEvents: nextErrors.errorSourceEvents, + validationSourceErrors: nextErrors.errorMap, } }) } - _setFieldValidatorError( + _setFieldValidationSourceError( field: AnyInternalFieldApi, - validatorIndex: number, + validationSource: AnyInternalValidationSourceInstance, errors: Array, sourceEvent: string, ) { field._setMeta((prev) => { - const nextErrors = setIndexedError( - prev._formValidatorErrors, - prev._formValidatorErrorSourceEvents, - validatorIndex, + const nextErrors = setValidationSourceError( + prev._validationSourceErrors, + validationSource, errors, sourceEvent, ) @@ -793,35 +806,48 @@ export class InternalFormApi< return { ...prev, - _formValidatorErrors: nextErrors.errors, - _formValidatorErrorSourceEvents: nextErrors.errorSourceEvents, + _validationSourceErrors: nextErrors.errorMap, } satisfies InternalBaseFieldMeta }) } - _clearFieldValidatorError( + _clearFieldValidationSourceError( field: AnyInternalFieldApi, - validatorIndex: number, + validationSource: AnyInternalValidationSourceInstance, ) { - if (field._getBaseMeta()._formValidatorErrors.length <= validatorIndex) { - field._pruneIfUnused() - return - } - - this._setFieldValidatorError(field, validatorIndex, [], '') + this._setFieldValidationSourceError(field, validationSource, [], '') + validationSource.deleteErrorTarget(field) field._pruneIfUnused() } + /** Removes all form-owned state associated with a disposed validator. */ + _removeValidatorInstance( + validatorInstance: InternalFormValidatorInstance, + ): void { + const affectedFields = new Set(validatorInstance.errorTargets ?? []) + + batch(() => { + this._setFormValidationSourceError(validatorInstance, [], '') + for (const field of affectedFields) { + this._clearFieldValidationSourceError(field, validatorInstance) + } + if (affectedFields.size > 0) { + this._atoms.meta.errorFields.set((prev) => + reconcileFormErrorFields(prev, affectedFields), + ) + } + }) + } + _clearFieldEventErrors( field: AnyInternalFieldApi, - eventErrorIndexes: Array, + validationSources: ReadonlyArray, sourceEvent: string, ) { field._setMeta((prev) => { - const clearedErrors = clearIndexedErrorsFromSource( - prev._formValidatorErrors, - prev._formValidatorErrorSourceEvents, - eventErrorIndexes, + const clearedErrors = clearValidationSourceErrorsFromEvent( + prev._validationSourceErrors, + validationSources, sourceEvent, ) @@ -829,8 +855,7 @@ export class InternalFormApi< return { ...prev, - _formValidatorErrors: clearedErrors.errors, - _formValidatorErrorSourceEvents: clearedErrors.errorSourceEvents, + _validationSourceErrors: clearedErrors.errorMap, } }) field._pruneIfUnused() @@ -840,9 +865,8 @@ export class InternalFormApi< result: PipelineResult>, sourceEvent: string, ) { - if (result.hasSchemaResult) { - this._schemaOutputs[result.validatorIndex] = result.schemaResult - } + const validatorInstance = + result.validatorInstance as InternalFormValidatorInstance const parsedResult = parseValidationResult(result.result) const resolvedFieldErrors = this._resolveRoutedFieldErrors( @@ -850,27 +874,79 @@ export class InternalFormApi< ) batch(() => { - this._setFormValidatorError( - result.validatorIndex, + this._setFormValidationSourceError( + validatorInstance, + parsedResult.self ?? [], + sourceEvent, + ) + + const oldFieldRefs = validatorInstance.errorTargets ?? undefined + const { fieldRefs, affectedFields, didFieldRefsChange } = + reconcileRoutedFieldErrors( + validatorInstance, + resolvedFieldErrors, + oldFieldRefs, + (field, instance, errors) => + this._setFieldValidationSourceError( + field, + instance as InternalFormValidatorInstance, + errors, + sourceEvent, + ), + (field, instance) => + this._clearFieldValidationSourceError( + field, + instance as InternalFormValidatorInstance, + ), + ) + + if (didFieldRefsChange) + validatorInstance.errorTargets = fieldRefs.size > 0 ? fieldRefs : null + + if (affectedFields.size > 0) { + this._atoms.meta.errorFields.set((prev) => + reconcileFormErrorFields(prev, affectedFields), + ) + } + }) + } + + _processSubmitValidationResult( + result: FormValidateResult, + sourceEvent: string, + ): void { + const parsedResult = parseValidationResult(result) + const resolvedFieldErrors = this._resolveRoutedFieldErrors( + Object.entries(parsedResult.subfields ?? {}), + ) + + batch(() => { + this._setFormValidationSourceError( + this._onSubmitSource, parsedResult.self ?? [], sourceEvent, ) - const fieldErrors = [...this._atoms.meta.fieldErrors.get()] - const oldFieldRefs = fieldErrors[result.validatorIndex] + const oldFieldRefs = this._onSubmitSource.errorTargets ?? undefined const { fieldRefs, affectedFields, didFieldRefsChange } = reconcileRoutedFieldErrors( - result.validatorIndex, + this._onSubmitSource, resolvedFieldErrors, oldFieldRefs, - (field, index, errors) => - this._setFieldValidatorError(field, index, errors, sourceEvent), - (field, index) => this._clearFieldValidatorError(field, index), + (field, validationSource, errors) => + this._setFieldValidationSourceError( + field, + validationSource, + errors, + sourceEvent, + ), + (field, validationSource) => + this._clearFieldValidationSourceError(field, validationSource), ) if (didFieldRefsChange) { - fieldErrors[result.validatorIndex] = fieldRefs - this._atoms.meta.fieldErrors.set(fieldErrors) + this._onSubmitSource.errorTargets = + fieldRefs.size > 0 ? fieldRefs : null } if (affectedFields.size > 0) { @@ -882,9 +958,15 @@ export class InternalFormApi< } _runMountValidation(): void { - const pipeline = this._options.validators + const pipeline = this._validatorInstances?.filter( + (validatorInstance) => !validatorInstance.didRunOnMount, + ) if (!pipeline || pipeline.length === 0) return + pipeline.forEach((validatorInstance) => + validatorInstance.markMountValidationRan(), + ) + this._setValidationCount((count) => count + 1) const { didRun, asyncPromise } = runFormMountValidatorPipeline({ @@ -915,7 +997,7 @@ export class InternalFormApi< hasFailedBefore?: boolean }, ): Promise { - const pipeline = this._options.validators + const pipeline = this._validatorInstances if (!pipeline) return { results: [], diff --git a/packages/form-core/src/FormApi/formState.lib.ts b/packages/form-core/src/FormApi/formState.lib.ts index 8758455b17..59b3fb861f 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -1,8 +1,7 @@ import { batch } from '@tanstack/store' import { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, - hasIndexedErrors, + clearValidationSourceErrorsFromEvent, + getValidationSourceErrors, } from '../validation' import type { FormState } from './FormApi.public' import type { InternalFormApi } from './FormApi.lib' @@ -14,15 +13,11 @@ import type { ValidationIssue, } from '../validation.public' import type { Atom } from '@tanstack/store' +import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' +import type { ValidationSourceErrorMap } from '../validation' export interface FormErrorMeta { - /** - * @private - * Dense 2-dimensional array of form-level errors where index corresponds to validatorIndex. - * Each validator index contains an array of errors (normalized). - */ - errors: Array> - errorSourceEvents: Array + validationSourceErrors: ValidationSourceErrorMap | null } type FormValidatorFieldErrorScope = @@ -30,16 +25,15 @@ type FormValidatorFieldErrorScope = | { type: 'field'; field: AnyInternalFieldApi } | { type: 'none' } -interface ClearFormValidatorErrorsFromSourceArgs { +interface ClearFormValidationSourceErrorsFromEventArgs { formErrors: Atom - fieldErrors: Atom>> errorFields: Atom> - indexes: Array + validationSources: ReadonlyArray sourceEvent: string fieldScope: FormValidatorFieldErrorScope clearFieldEventErrors: ( field: AnyInternalFieldApi, - indexes: Array, + validationSources: ReadonlyArray, sourceEvent: string, ) => void reconcileErrorFields?: boolean @@ -93,55 +87,31 @@ function getFormErrors( const baseFormErrors = form._atoms.meta.formErrors.get() let formErrors = formErrorsCache.get(baseFormErrors) if (!formErrors) { - formErrors = baseFormErrors.errors.flat() + formErrors = getValidationSourceErrors( + baseFormErrors.validationSourceErrors, + ) formErrorsCache.set(baseFormErrors, formErrors) } return formErrors } -function hasFormValidatorFieldEventErrors( +function hasFormValidationSourceFieldEventError( field: AnyInternalFieldApi, - indexes: Array, + validationSource: AnyInternalValidationSourceInstance, sourceEvent: string, ): boolean { - for (const index of indexes) { - if (hasFormValidatorFieldEventError(field, index, sourceEvent)) { - return true - } - } - - return false -} - -function hasFormValidatorFieldEventError( - field: AnyInternalFieldApi, - index: number, - sourceEvent: string, -): boolean { - const { _formValidatorErrors, _formValidatorErrorSourceEvents } = - field._getBaseMeta() - - return hasIndexedErrorFromSource( - _formValidatorErrors, - _formValidatorErrorSourceEvents, - index, - sourceEvent, + return ( + field._getBaseMeta()._validationSourceErrors?.get(validationSource) + ?.sourceEvent === sourceEvent ) } function hasFieldErrors(field: AnyInternalFieldApi): boolean { const meta = field._getBaseMeta() - if (hasIndexedErrors(meta._fieldValidatorErrors)) return true - if (hasIndexedErrors(meta._formValidatorErrors)) return true + if (meta._validationSourceErrors) return true if (meta.childContributionCounts.error > 0) return true - if (meta._formGroupValidatorErrors !== null) { - if (hasIndexedErrors(meta._formGroupValidatorErrors.errors)) { - return true - } - } - return false } @@ -162,97 +132,78 @@ export function reconcileFormErrorFields( return nextErrorFields } -export function clearFormValidatorErrorsFromSource({ +export function clearFormValidationSourceErrorsFromEvent({ formErrors, - fieldErrors, errorFields, - indexes, + validationSources, sourceEvent, fieldScope, clearFieldEventErrors, reconcileErrorFields = false, -}: ClearFormValidatorErrorsFromSourceArgs): void { +}: ClearFormValidationSourceErrorsFromEventArgs): void { const affectedFields = new Set() batch(() => { formErrors.set((prev) => { - const clearedErrors = clearIndexedErrorsFromSource( - prev.errors, - prev.errorSourceEvents, - indexes, + const clearedErrors = clearValidationSourceErrorsFromEvent( + prev.validationSourceErrors, + validationSources, sourceEvent, ) if (!clearedErrors) return prev return { ...prev, - errors: clearedErrors.errors, - errorSourceEvents: clearedErrors.errorSourceEvents, + validationSourceErrors: clearedErrors.errorMap, } }) - if (fieldScope.type === 'all') { - fieldErrors.set((prev) => { - let nextFieldErrors: Array> | null = null - - for (const validatorIndex of indexes) { - const fieldRefs = prev[validatorIndex] - if (!fieldRefs || fieldRefs.size === 0) continue - - let nextFieldRefs: Set | null = null - for (const field of fieldRefs) { - if ( - hasFormValidatorFieldEventError( - field, - validatorIndex, - sourceEvent, - ) - ) { - nextFieldRefs ??= new Set(fieldRefs) - nextFieldRefs.delete(field) - affectedFields.add(field) - } - } - - if (nextFieldRefs) { - nextFieldErrors ??= [...prev] - nextFieldErrors[validatorIndex] = nextFieldRefs - } - } + const instancesByField = new Map< + AnyInternalFieldApi, + Array + >() - return nextFieldErrors ?? prev - }) - } else if (fieldScope.type === 'field') { - const { field } = fieldScope - - fieldErrors.set((prev) => { - let nextFieldErrors: Array> | null = null - - for (const validatorIndex of indexes) { - const fieldRefs = prev[validatorIndex] + if (fieldScope.type === 'all') { + for (const validationSource of validationSources) { + for (const field of validationSource.errorTargets ?? []) { if ( - !fieldRefs?.has(field) || - !hasFormValidatorFieldEventError(field, validatorIndex, sourceEvent) + !hasFormValidationSourceFieldEventError( + field, + validationSource, + sourceEvent, + ) ) { continue } - const nextFieldRefs = new Set(fieldRefs) - nextFieldRefs.delete(field) - nextFieldErrors ??= [...prev] - nextFieldErrors[validatorIndex] = nextFieldRefs + const fieldInstances = instancesByField.get(field) ?? [] + fieldInstances.push(validationSource) + instancesByField.set(field, fieldInstances) + affectedFields.add(field) } - - return nextFieldErrors ?? prev - }) - - if (hasFormValidatorFieldEventErrors(field, indexes, sourceEvent)) { + } + } else if (fieldScope.type === 'field') { + const { field } = fieldScope + const fieldInstances = validationSources.filter( + (validationSource) => + validationSource.errorTargets?.has(field) && + hasFormValidationSourceFieldEventError( + field, + validationSource, + sourceEvent, + ), + ) + if (fieldInstances.length > 0) { + instancesByField.set(field, fieldInstances) affectedFields.add(field) } } - for (const field of affectedFields) { - clearFieldEventErrors(field, indexes, sourceEvent) + for (const [field, instances] of instancesByField) { + clearFieldEventErrors(field, instances, sourceEvent) + instances.forEach((validationSource) => + validationSource.deleteErrorTarget(field), + ) } if (reconcileErrorFields && affectedFields.size > 0) { diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index cf278b212d..b35a39deb6 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -159,12 +159,8 @@ export async function runSubmissionProcess( return finishInvalidSubmission(form.state.values) } - const schemaOutputs: any = Array.from( - { length: form._options.validators?.length ?? 0 }, - (_, i) => { - return form._schemaOutputs[i] - }, - ) + const schemaOutputs = + form._validatorInstances?.map((v) => v.schemaOutput) ?? [] const value = form.state.values try { @@ -180,26 +176,12 @@ export async function runSubmissionProcess( return [] } - // Attach onSubmit errors at the end of the validators array + // Store onSubmit errors separately from installed validator instances. if (isSubmitError(maybeError)) { - form._processValidationResult( - { - validatorIndex: form._options.validators?.length ?? 0, - result: maybeError, - schemaResult: null, - }, - 'submit', - ) + form._processSubmitValidationResult(maybeError, 'submit') submissionData.submitError = maybeError } else { - form._processValidationResult( - { - validatorIndex: form._options.validators?.length ?? 0, - result: null, - schemaResult: null, - }, - 'submit', - ) + form._processSubmitValidationResult(null, 'submit') } } catch (e) { if (hasResettedFormDuringSubmit()) { @@ -214,15 +196,6 @@ export async function runSubmissionProcess( if (isErrorResult(submissionData.submitError)) { submissionData.hasFailed = true errorResults.push(submissionData.submitError) - - form._processValidationResult( - { - validatorIndex: form._options.validators?.length ?? 0, - result: submissionData.submitError, - schemaResult: null, - }, - 'submit', - ) } }) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 4292e6e76f..439591ce6d 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -1,22 +1,15 @@ import { batch, createAtom, shallow } from '@tanstack/store' +import { concatenateFieldNames, getBy, setBy } from '../utils.lib' import { - cancelPipelineCache, - concatenateFieldNames, - createPipelineCache, - getBy, - setBy, -} from '../utils.lib' -import { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, - hasIndexedErrors, + clearValidationSourceErrorsFromEvent, + getValidationSourceErrors, isErrorResult, isValidationTriggerEnabled, parseValidationResult, reconcileRoutedFieldErrors, runGroupMountValidatorPipeline, runValidatorPipeline, - setIndexedError, + setValidationSourceError, } from '../validation' import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' import { visitFieldSubtree } from '../FieldApi/fieldTraversal.lib' @@ -26,6 +19,7 @@ import { } from '../FieldApi/fieldState.lib' import { parseStandardSchemaIssues } from '../standardSchema.lib' import { createErrorMap } from '../validation.public' +import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import type { FormApi } from '../FormApi/FormApi.public' import type { InternalFormApi } from '../FormApi/FormApi.lib' import type { @@ -34,13 +28,11 @@ import type { } from '../FieldApi/FieldApi.lib' import type { DerivedMetaMarkers, - FormGroupFieldErrorMeta, InternalBaseFieldMeta, InternalFieldMeta, } from '../FieldApi/fieldState.lib' import type { FormStateOverrides } from '../FormApi/formState.lib' import type { DeepKeys, DeepValue } from '../deep-keys.public' -import type { PipelineCache } from '../utils.lib' import type { FormGroupApi, FormGroupOptions, @@ -57,17 +49,16 @@ import type { ValidationIssue, } from '../validation.public' import type { ReadonlyAtom } from '@tanstack/store' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' interface FormGroupValidationOutcome { errors: Array> hasException: boolean } -const emptyFormGroupFieldErrorMeta: FormGroupFieldErrorMeta = { - errors: [], - errorSourceEvents: [], -} - export type AnyInternalFormGroupApi = InternalFormGroupApi< any, any, @@ -75,6 +66,11 @@ export type AnyInternalFormGroupApi = InternalFormGroupApi< any, any > +export type InternalGroupValidatorInstance = InternalValidatorInstance< + FormGroupValidator, + AnyInternalFormGroupApi, + AnyInternalFieldApi +> export class InternalFormGroupApi< TFormData, @@ -100,13 +96,15 @@ export class InternalFormGroupApi< TGroupValidators, TFormErrorTypes > + /** Stable runtime instances correlated with `_options.validators` by slot. */ + _validatorInstances: InternalValidatorInstances< + TGroupValidators[number], + AnyInternalFormGroupApi, + AnyInternalFieldApi + > atom: ReadonlyAtom< FormGroupState> > - _pipelineCache: PipelineCache> - _schemaOutputs: Array = [] - /** Tracks the trie nodes receiving routed errors from each validator. */ - _routedErrorFields: Array | undefined> = [] _isSubmitting = createAtom(false) _isSubmitSuccessful = createAtom(false) _submissionAttempts = createAtom(0) @@ -140,7 +138,16 @@ export class InternalFormGroupApi< this.form = options.form as never this._groupField = this.form._getOrCreateFieldApi({ name: options.name }) this._groupField._setFormGroup(this) - this._pipelineCache = createPipelineCache() + this._validatorInstances = reconcileValidatorInstances< + TGroupValidators[number], + AnyInternalFormGroupApi, + AnyInternalFieldApi + >({ + definitions: this._options.validators, + instances: null, + owner: this, + scope: 'group', + }) const groupMetaMarkers: DerivedMetaMarkers = { source: undefined, @@ -164,16 +171,19 @@ export class InternalFormGroupApi< groupValue, groupMetaMarkers, ) - const groupErrorMeta = groupBaseMeta._formGroupValidatorErrors + const groupErrorMeta = groupBaseMeta._validationSourceErrors let groupErrors: FormErrors> if ( prev && - previousGroupMeta?._formGroupValidatorErrors === groupErrorMeta + previousGroupMeta?._validationSourceErrors === groupErrorMeta ) { groupErrors = prev.errors } else if (groupErrorMeta) { - groupErrors = groupErrorMeta.errors.flat() as never + groupErrors = getValidationSourceErrors( + groupErrorMeta, + this._validatorInstances, + ) as never } else { groupErrors = [] } @@ -234,8 +244,7 @@ export class InternalFormGroupApi< /** Cancels group validation and clears its field-meta contributions. */ _cancelValidation(): void { - cancelPipelineCache(this._pipelineCache) - this._pipelineCache = createPipelineCache() + this._validatorInstances?.forEach((instance) => instance.cancelExecution()) for (const finishValidation of Array.from(this._validationCountCleanups)) { finishValidation() } @@ -250,19 +259,39 @@ export class InternalFormGroupApi< TFormErrorTypes >, ) => { + const previousValidators = this._options.validators this._options = options + this._validatorInstances = reconcileValidatorInstances< + TGroupValidators[number], + AnyInternalFormGroupApi, + AnyInternalFieldApi + >({ + definitions: this._options.validators, + previousDefinitions: previousValidators ?? null, + instances: this._validatorInstances, + owner: this, + scope: 'group', + onBeforeDispose: (validatorInstance) => + this._removeValidatorInstance(validatorInstance), + }) } mount = (): void => { this._attachToFieldTrie(this.name) - const pipeline = this._options.validators + const pipeline = this._validatorInstances?.filter( + (validatorInstance) => !validatorInstance.didRunOnMount, + ) if (!pipeline || pipeline.length === 0) return + pipeline.forEach((validatorInstance) => + validatorInstance.markMountValidationRan(), + ) + const finishValidation = this._startValidation() const { didRun, asyncPromise } = runGroupMountValidatorPipeline({ - pipeline: pipeline as ReadonlyArray>, + pipeline, groupApi: this, onResult: (result) => this._processValidationResult(result, 'mount'), }) @@ -335,73 +364,65 @@ export class InternalFormGroupApi< ) } - _setGroupFieldErrorMeta( - meta: InternalBaseFieldMeta, - groupErrors: FormGroupFieldErrorMeta, - ): InternalBaseFieldMeta { - const formGroupValidatorErrors = hasIndexedErrors(groupErrors.errors) - ? groupErrors - : null - - if (meta._formGroupValidatorErrors === formGroupValidatorErrors) return meta - - return { - ...meta, - _formGroupValidatorErrors: formGroupValidatorErrors, - } - } - _setFieldValidatorError( field: AnyInternalFieldApi, - validatorIndex: number, + validatorInstance: InternalGroupValidatorInstance, errors: Array, sourceEvent: string, ) { field._setMeta((prev) => { - const previousGroupErrors = this._getFieldErrorMeta(prev) - const nextErrors = setIndexedError( - previousGroupErrors.errors, - previousGroupErrors.errorSourceEvents, - validatorIndex, + const nextErrors = setValidationSourceError( + prev._validationSourceErrors, + validatorInstance, errors, sourceEvent, ) if (!nextErrors) return prev - return this._setGroupFieldErrorMeta(prev, nextErrors) + return { + ...prev, + _validationSourceErrors: nextErrors.errorMap, + } }) } - _getFieldErrorMeta(meta: InternalBaseFieldMeta): FormGroupFieldErrorMeta { - return meta._formGroupValidatorErrors ?? emptyFormGroupFieldErrorMeta - } - _clearFieldValidatorError( field: AnyInternalFieldApi, - validatorIndex: number, + validatorInstance: InternalGroupValidatorInstance, ) { - this._setFieldValidatorError(field, validatorIndex, [], '') + this._setFieldValidatorError(field, validatorInstance, [], '') + validatorInstance.deleteErrorTarget(field) field._pruneIfUnused() } + /** Removes all group-owned field errors associated with a disposed validator. */ + _removeValidatorInstance( + validatorInstance: InternalGroupValidatorInstance, + ): void { + const fields = Array.from(validatorInstance.errorTargets ?? []) + batch(() => { + for (const field of fields) { + this._clearFieldValidatorError(field, validatorInstance) + } + }) + } + _processValidationResult( result: { - validatorIndex: number + validatorInstance: InternalGroupValidatorInstance result: FormGroupValidateResult schemaResult: any | null hasSchemaResult?: boolean }, sourceEvent: string, ) { - if (result.hasSchemaResult) { - this._schemaOutputs[result.validatorIndex] = result.schemaResult - } - const parsedResult = parseValidationResult(result.result) - const validatorIndex = result.validatorIndex const groupField = this._groupField - const oldFieldRefs = this._routedErrorFields[validatorIndex] + const oldFieldRefs = result.validatorInstance.errorTargets + ? new Set(result.validatorInstance.errorTargets) + : undefined + oldFieldRefs?.delete(groupField) const resolvedFieldErrors = this.form._resolveRoutedFieldErrors( Object.entries(parsedResult.subfields ?? {}), groupField, @@ -412,21 +433,34 @@ export class InternalFormGroupApi< batch(() => { this._setFieldValidatorError( groupField, - validatorIndex, + result.validatorInstance, (parsedResult.self ?? []).concat(groupFieldErrors), sourceEvent, ) const { fieldRefs } = reconcileRoutedFieldErrors( - validatorIndex, + result.validatorInstance, resolvedFieldErrors, oldFieldRefs, - (field, index, errors) => - this._setFieldValidatorError(field, index, errors, sourceEvent), - (field, index) => this._clearFieldValidatorError(field, index), + (field, instance, errors) => + this._setFieldValidatorError( + field, + instance as InternalGroupValidatorInstance, + errors, + sourceEvent, + ), + (field, instance) => + this._clearFieldValidatorError( + field, + instance as InternalGroupValidatorInstance, + ), ) - this._routedErrorFields[validatorIndex] = fieldRefs + if ((parsedResult.self?.length ?? 0) + groupFieldErrors.length > 0) { + fieldRefs.add(groupField) + } + result.validatorInstance.errorTargets = + fieldRefs.size > 0 ? fieldRefs : null }) } @@ -463,36 +497,33 @@ export class InternalFormGroupApi< _hasFieldEventError( field: AnyInternalFieldApi, - validatorIndex: number, + validatorInstance: InternalGroupValidatorInstance, sourceEvent: string, ): boolean { - const meta = field._getBaseMeta() - const groupErrors = this._getFieldErrorMeta(meta) - return hasIndexedErrorFromSource( - groupErrors.errors, - groupErrors.errorSourceEvents, - validatorIndex, - sourceEvent, + return ( + field._getBaseMeta()._validationSourceErrors?.get(validatorInstance) + ?.sourceEvent === sourceEvent ) } _clearFieldEventErrors( field: AnyInternalFieldApi, - validatorIndexes: Array, + validatorInstances: Array, sourceEvent: string, ) { field._setMeta((prev) => { - const previousGroupErrors = this._getFieldErrorMeta(prev) - const clearedErrors = clearIndexedErrorsFromSource( - previousGroupErrors.errors, - previousGroupErrors.errorSourceEvents, - validatorIndexes, + const clearedErrors = clearValidationSourceErrorsFromEvent( + prev._validationSourceErrors, + validatorInstances, sourceEvent, ) if (!clearedErrors) return prev - return this._setGroupFieldErrorMeta(prev, clearedErrors) + return { + ...prev, + _validationSourceErrors: clearedErrors.errorMap, + } }) field._pruneIfUnused() } @@ -502,94 +533,100 @@ export class InternalFormGroupApi< sourceEvent: string, event: ConfigurableValidationTrigger, ) { - const validatorCount = this._options.validators?.length ?? 0 const groupField = this._groupField - const eventErrorCount = Math.max( - validatorCount, - this._routedErrorFields.length, - this._getFieldErrorMeta(groupField._getBaseMeta()).errors.length, - this._getFieldErrorMeta(field._getBaseMeta()).errors.length, - ) - const eventErrorIndexes: Array = [] - - for (let i = 0; i < validatorCount; i++) { - const validator = this._options.validators?.[i] - const runsOnEvent = validator?.triggers.some((trigger) => - isValidationTriggerEnabled(trigger, { - scope: 'group', - event, - formApi: this.form, - groupApi: this, - triggerFieldApi: field, - }), + const validatorInstancesToClear: Array = [] + + for (const validatorInstance of this._validatorInstances ?? []) { + const runsOnEvent = validatorInstance.definition.triggers.some( + (trigger) => + isValidationTriggerEnabled(trigger, { + scope: 'group', + event, + formApi: this.form, + groupApi: this, + triggerFieldApi: field, + }), ) - if (validator && !runsOnEvent) { - eventErrorIndexes.push(i) + if (!runsOnEvent) { + validatorInstancesToClear.push(validatorInstance) } } - for (let i = validatorCount; i < eventErrorCount; i++) { - eventErrorIndexes.push(i) - } - - if (eventErrorIndexes.length === 0) return + if (validatorInstancesToClear.length === 0) return batch(() => { - this._clearFieldEventErrors(groupField, eventErrorIndexes, sourceEvent) + const groupInstances = validatorInstancesToClear.filter( + (validatorInstance) => + this._hasFieldEventError(groupField, validatorInstance, sourceEvent), + ) + this._clearFieldEventErrors(groupField, groupInstances, sourceEvent) + groupInstances.forEach((validatorInstance) => + validatorInstance.deleteErrorTarget(groupField), + ) - const indexesToClearFromField: Array = [] - for (const validatorIndex of eventErrorIndexes) { - const fieldRefs = this._routedErrorFields[validatorIndex] + const instancesToClearFromField: Array = + [] + for (const validatorInstance of validatorInstancesToClear) { if ( - fieldRefs?.has(field) && - this._hasFieldEventError(field, validatorIndex, sourceEvent) + validatorInstance.errorTargets?.has(field) && + this._hasFieldEventError(field, validatorInstance, sourceEvent) ) { - const nextFieldRefs = new Set(fieldRefs) - nextFieldRefs.delete(field) - this._routedErrorFields[validatorIndex] = nextFieldRefs - indexesToClearFromField.push(validatorIndex) + validatorInstance.deleteErrorTarget(field) + instancesToClearFromField.push(validatorInstance) } } - if (indexesToClearFromField.length > 0) { - this._clearFieldEventErrors(field, indexesToClearFromField, sourceEvent) + if (instancesToClearFromField.length > 0) { + this._clearFieldEventErrors( + field, + instancesToClearFromField, + sourceEvent, + ) } }) } _clearRoutedErrors() { + const validatorInstances = this._validatorInstances ?? [] + if (validatorInstances.length === 0) return + const fields = new Set() fields.add(this._groupField) - for (const fieldRefs of this._routedErrorFields) { - for (const field of fieldRefs ?? []) fields.add(field) + for (const validatorInstance of validatorInstances) { + for (const field of validatorInstance.errorTargets ?? []) { + fields.add(field) + } + validatorInstance.errorTargets = null } for (const field of fields) { field._setMeta((prev) => { - if (!prev._formGroupValidatorErrors) return prev + const previousErrors = prev._validationSourceErrors + if (!previousErrors) return prev + + let nextErrors: typeof previousErrors | null = null + for (const validatorInstance of validatorInstances) { + if (!previousErrors.has(validatorInstance)) continue + + nextErrors ??= new Map(previousErrors) + nextErrors.delete(validatorInstance) + } + if (!nextErrors) return prev + return { ...prev, - _formGroupValidatorErrors: null, + _validationSourceErrors: nextErrors.size > 0 ? nextErrors : null, } }) field._pruneIfUnused() } - this._routedErrorFields = [] } - // TODO: Remove this targeted cleanup when routed errors migrate to the new - // error state structure. Until then, replacement must drop stale field refs. _removeRoutedErrorFields(fieldsToRemove: ReadonlySet) { - for (let index = 0; index < this._routedErrorFields.length; index++) { - const fieldRefs = this._routedErrorFields[index] - if (!fieldRefs) continue - - const liveFieldRefs = new Set( - Array.from(fieldRefs).filter((field) => !fieldsToRemove.has(field)), - ) - if (liveFieldRefs.size !== fieldRefs.size) { - this._routedErrorFields[index] = liveFieldRefs + for (const validatorInstance of this._validatorInstances ?? []) { + for (const field of fieldsToRemove) { + validatorInstance.deleteErrorTarget(field) } } } @@ -605,7 +642,7 @@ export class InternalFormGroupApi< } } - const pipeline = this._options.validators + const pipeline = this._validatorInstances if (!pipeline || pipeline.length === 0) { const fieldOutcome = await this._runFieldValidations(signal) this._clearRoutedErrors() @@ -618,8 +655,7 @@ export class InternalFormGroupApi< const results = await runValidatorPipeline< FormGroupValidateResult >({ - pipeline: pipeline as ReadonlyArray>, - cache: this._pipelineCache, + pipeline, context: { scope: 'group', event: signal, @@ -703,7 +739,10 @@ export class InternalFormGroupApi< value, formApi: this.form, groupApi: this, - schemaOutputs: this._schemaOutputs, + schemaOutputs: Array.from( + this._validatorInstances ?? [], + (validatorInstance) => validatorInstance.schemaOutput, + ), } as never try { @@ -722,7 +761,6 @@ export class InternalFormGroupApi< reset = () => { this._cancelValidation() - this._schemaOutputs = [] this.form._atoms.values.set((prev: TFormData) => setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), ) @@ -743,16 +781,20 @@ export class InternalFormGroupApi< this._submissionAttempts.set(0) this._clearRoutedErrors() }) + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) } _cleanup() { this._cancelValidation() - this._schemaOutputs = [] batch(() => { this._isSubmitting.set(false) this._isSubmitSuccessful.set(false) this._clearRoutedErrors() }) + this._validatorInstances?.forEach((instance) => { + instance.resetRuntime() + instance.resetMountValidation() + }) this._groupField._setFormGroup(null) } } diff --git a/packages/form-core/src/ValidationSourceInstance.lib.ts b/packages/form-core/src/ValidationSourceInstance.lib.ts new file mode 100644 index 0000000000..5be70c5272 --- /dev/null +++ b/packages/form-core/src/ValidationSourceInstance.lib.ts @@ -0,0 +1,105 @@ +import type { ValidatorScope } from './validation.public' + +/** Internal validation sources ordered by their contribution to derived errors. */ +export const validationSourceScopes = { + field: 0, + group: 1, + form: 2, + onSubmit: 3, +} as const + +export type InternalValidationSourceScope = ValidatorScope | 'onSubmit' + +export type InternalValidationSourceScopePriority = + (typeof validationSourceScopes)[InternalValidationSourceScope] + +export interface InternalValidationSourceInstanceOptions { + owner: TOwner + scope: InternalValidationSourceScope + index?: number +} + +export type AnyInternalValidationSourceInstance = + InternalValidationSourceInstance + +/** Stable identity and ordering metadata for one internal validation source. */ +export class InternalValidationSourceInstance { + /** The validation boundary that owns this source. */ + readonly owner: TOwner + /** Numeric priority used to order errors across validation scopes. */ + readonly scope: InternalValidationSourceScopePriority + /** This source's position within its scope's validation pipeline. */ + readonly index: number + /** Targets currently receiving errors routed from this source. */ + errorTargets: Set | null = null + /** Whether this source has been permanently disposed. */ + disposed = false + + /** Creates a stable source with fixed ownership and ordering metadata. */ + constructor({ + owner, + scope, + index = 0, + }: InternalValidationSourceInstanceOptions) { + this.owner = owner + this.scope = validationSourceScopes[scope] + this.index = index + } + + /** Records a target receiving errors from this source. */ + addErrorTarget(errorTarget: TErrorTarget): void { + if (this.disposed) return + + if (!this.errorTargets) { + this.errorTargets = new Set() + } + this.errorTargets.add(errorTarget) + } + + /** Stops tracking a target that no longer receives errors from this source. */ + deleteErrorTarget(errorTarget: TErrorTarget): void { + if (this.disposed) return + + this.errorTargets?.delete(errorTarget) + if (this.errorTargets?.size === 0) { + this.errorTargets = null + } + } + + /** Clears transient state while preserving this source's stable identity. */ + resetRuntime(): void { + if (this.disposed) return + + this._resetRuntime() + this.errorTargets = null + } + + /** Permanently disposes this source and releases its transient state. */ + dispose(onBeforeDispose?: (instance: this) => void): void { + if (this.disposed) return + + onBeforeDispose?.(this) + this._disposeRuntime() + this.errorTargets = null + this.disposed = true + } + + /** Releases source-specific transient state before common state is cleared. */ + protected _resetRuntime(): void {} + + /** Releases source-specific permanent state during disposal. */ + protected _disposeRuntime(): void { + this._resetRuntime() + } +} + +/** Orders sources first by scope priority and then by pipeline position. */ +export function compareValidationSources( + left: AnyInternalValidationSourceInstance, + right: AnyInternalValidationSourceInstance, +): number { + if (left.scope === right.scope) { + return left.index - right.index + } + return left.scope - right.scope +} diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts new file mode 100644 index 0000000000..0a8e321ec3 --- /dev/null +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -0,0 +1,399 @@ +import { LiteDebouncer } from '@tanstack/pacer-lite' +import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib' +import type { StandardSchemaV1 } from './standardSchema.public' +import type { + BaseValidator, + ValidatorFn, + ValidatorScope, +} from './validation.public' + +export type InternalValidatorDefinition = BaseValidator< + StandardSchemaV1 | ValidatorFn +> + +export type ValidatorInstanceDebouncedFn = (...args: Array) => any + +export type AnyInternalValidatorInstance< + TDebouncedFn extends ValidatorInstanceDebouncedFn = + ValidatorInstanceDebouncedFn, +> = InternalValidatorInstance + +export interface InternalValidatorInstanceOptions< + out TDefinition extends InternalValidatorDefinition, + out TOwner, +> { + definition: TDefinition + owner: TOwner + scope: ValidatorScope + index?: number +} + +/** Stable runtime instances correlated with validator definitions by slot. */ +export type InternalValidatorInstances< + TDefinition extends InternalValidatorDefinition, + TOwner, + TErrorTarget = unknown, + TWatchedField = unknown, + TSchemaOutput = unknown, +> = Array< + InternalValidatorInstance< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput + > +> | null + +export interface ReconcileValidatorInstancesOptions< + TDefinition extends InternalValidatorDefinition, + TOwner, + TErrorTarget = unknown, + TWatchedField = unknown, + TSchemaOutput = unknown, +> { + /** The latest validator definitions installed on the owner. */ + definitions: ReadonlyArray | null | undefined + /** + * The definitions installed before an update. + * + * Omit during initialization. Pass `null` when updating an owner that + * previously had no validators. + */ + previousDefinitions?: ReadonlyArray | null + /** The owner's currently installed instances, if it has any. */ + instances: InternalValidatorInstances< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput + > + /** The validation boundary that owns every reconciled instance. */ + owner: TOwner + /** The form, group, or field scope shared by the reconciled instances. */ + scope: ValidatorScope + /** Cleans owner-specific state before an instance is permanently disposed. */ + onBeforeDispose?: ( + instance: InternalValidatorInstance< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput + >, + ) => void +} + +/** + * Runtime state owned by one installed validator occurrence. + */ +export class InternalValidatorInstance< + TDefinition extends InternalValidatorDefinition, + TOwner, + TErrorTarget = unknown, + TWatchedField = unknown, + TSchemaOutput = unknown, + TDebouncedFn extends ValidatorInstanceDebouncedFn = + ValidatorInstanceDebouncedFn, +> extends InternalValidationSourceInstance { + /** The current validator definition associated with this stable instance. */ + definition: TDefinition + /** The controller for the active execution, or `null` when none is active. */ + abortController: AbortController | null = null + /** The lazily created debouncer for this validator's pending execution. */ + debouncer: LiteDebouncer | null = null + /** + * The Standard Schema output assigned by the latest form or group submit. + * + * Submit pipelines cancel prior executions and clear this before evaluating + * their validators. Field and non-submit pipelines do not assign it. Consult + * `hasSchemaOutput` because `undefined` can itself be a stored output. + */ + schemaOutput: TSchemaOutput | undefined + /** Whether the current submit pipeline assigned `schemaOutput`. */ + hasSchemaOutput = false + /** + * Resolved fields referenced by this validator's `watchFields` definition. + * + * The map is allocated on first use and returns to `null` when empty. + */ + resolvedWatchFields: Map | null = null + /** Whether this occurrence has already run its mount validation. */ + didRunOnMount = false + /** + * Number of definition updates applied while preserving this instance. + * + * Assigning the same definition again still advances the revision. + */ + revision = 0 + /** Creates the runtime state for one installed validator occurrence. */ + constructor({ + definition, + owner, + scope, + index, + }: InternalValidatorInstanceOptions) { + super({ owner, scope, index }) + this.definition = definition + } + + /** + * Replaces the definition while preserving this instance and its runtime state. + * + * Every update advances `revision`, including assignment of the same object. + * The operation is ignored after disposal. + */ + updateDefinition(definition: TDefinition): void { + if (this.disposed) return + + this.definition = definition + this.revision++ + } + + /** + * Installs the controller for the next active execution. + * + * A different previously installed controller is aborted before replacement. + * Reinstalling the same controller or calling this after disposal has no effect. + */ + setAbortController(abortController: AbortController): void { + if (this.disposed) return + if (this.abortController === abortController) return + + this.abortController?.abort() + this.abortController = abortController + } + + /** + * Clears an active controller only if it is still the installed controller. + * + * The identity check prevents completion of an older execution from clearing + * the controller of a newer one. This method does not abort the controller. + */ + clearAbortController(abortController: AbortController): void { + if (this.disposed) return + if (this.abortController !== abortController) return + + this.abortController = null + } + + /** + * Returns this validator's debouncer, creating it on first use. + * + * An existing debouncer is reused with its callback and wait duration updated. + * Returns `null` after disposal. + */ + getOrCreateDebouncer( + fn: TDebouncedFn, + wait: number, + ): LiteDebouncer | null { + if (this.disposed) return null + + let debouncer = this.debouncer + if (!debouncer) { + debouncer = new LiteDebouncer(fn, { wait }) + this.debouncer = debouncer + } else { + debouncer.fn = fn + debouncer.options.wait = wait + } + + return debouncer + } + + /** + * Stores a submit pipeline's Standard Schema output when its result has one. + * + * A result without a schema output leaves the current state unchanged. An + * explicit `undefined` is still considered a stored output. The operation is + * ignored after disposal. + * + * @param result - The accepted result produced by the validator pipeline. + */ + setSchemaOutput(result: { + schemaResult: TSchemaOutput | null + hasSchemaResult: boolean + }): void { + if (this.disposed || !result.hasSchemaResult) return + + this.schemaOutput = result.schemaResult as TSchemaOutput + this.hasSchemaOutput = true + } + + /** Clears the stored schema output and its presence marker. */ + clearSchemaOutput(): void { + if (this.disposed) return + + this._clearSchemaOutput() + } + + /** + * Associates a configured watched-field name with its resolved field. + * + * The backing map is allocated lazily, and an existing entry for `name` is + * replaced. The operation is ignored after disposal. + */ + setResolvedWatchField(name: string, field: TWatchedField): void { + if (this.disposed) return + + if (!this.resolvedWatchFields) { + this.resolvedWatchFields = new Map() + } + this.resolvedWatchFields.set(name, field) + } + + /** + * Removes a resolved watched field. + */ + deleteResolvedWatchField(name: string): void { + if (this.disposed) return + + this.resolvedWatchFields?.delete(name) + if (this.resolvedWatchFields?.size === 0) { + this.resolvedWatchFields = null + } + } + + /** Marks mount validation as completed for this occurrence. */ + markMountValidationRan(): void { + if (this.disposed) return + + this.didRunOnMount = true + } + + /** Allows a lifecycle owner to run mount validation again after remounting. */ + resetMountValidation(): void { + if (this.disposed) return + + this.didRunOnMount = false + } + + /** + * Aborts the active execution and cancels any pending debounced execution. + * + * Both execution resources are released. The operation is ignored after + * disposal. + */ + cancelExecution(): void { + if (this.disposed) return + + this._cancelExecution() + } + + /** + * Clears transient execution, schema-output, and error-target state. + * + * The definition, owner, scope, watched fields, mount marker, and revision are + * preserved. The operation is ignored after disposal. + */ + resetRuntime(): void { + super.resetRuntime() + } + + /** Releases validator-specific runtime state during reset or disposal. */ + protected override _resetRuntime(): void { + this._cancelExecution() + this._clearSchemaOutput() + } + + /** Releases validator-only collections and mount state during disposal. */ + protected override _disposeRuntime(): void { + this._resetRuntime() + this.resolvedWatchFields = null + this.didRunOnMount = false + } + + /** Cancels and releases execution resources without checking disposal state. */ + private _cancelExecution(): void { + this.abortController?.abort() + this.abortController = null + this.debouncer?.cancel() + this.debouncer = null + } + + /** Clears the schema-output value and presence marker as one operation. */ + private _clearSchemaOutput(): void { + this.schemaOutput = undefined + this.hasSchemaOutput = false + } +} + +/** + * Correlates validator definitions with their stable runtime instances by slot. + * + * Retained slots preserve their instance and receive the latest definition. + * Added slots create instances, while removed slots are permanently disposed. + * Missing and empty definition collections are normalized to `null`. + * Updates with a different definition count emit the shared invariant warning. + */ +export function reconcileValidatorInstances< + TDefinition extends InternalValidatorDefinition, + TOwner, + TErrorTarget = unknown, + TWatchedField = unknown, + TSchemaOutput = unknown, +>({ + definitions, + previousDefinitions, + instances, + owner, + scope, + onBeforeDispose, +}: ReconcileValidatorInstancesOptions< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput +>): InternalValidatorInstances< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput +> { + if ( + previousDefinitions !== undefined && + (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) + ) { + console.warn( + 'TanStack Form: The length of the validator array should not change after initialization', + ) + } + + if (!definitions || definitions.length === 0) { + instances?.forEach((instance) => { + instance.dispose(onBeforeDispose) + }) + return null + } + + const nextInstances = instances ?? [] + + definitions.forEach((definition, index) => { + const instance = nextInstances[index] + + if (instance) { + instance.updateDefinition(definition) + } else { + nextInstances[index] = new InternalValidatorInstance< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput + >({ definition, owner, scope, index }) + } + }) + + for (let index = definitions.length; index < nextInstances.length; index++) { + const instance = nextInstances[index] + if (!instance) continue + + instance.dispose(onBeforeDispose) + } + nextInstances.length = definitions.length + + return nextInstances +} diff --git a/packages/form-core/src/devtoolsBridge.lib.ts b/packages/form-core/src/devtoolsBridge.lib.ts index 8c52275f6d..c34c2b30d1 100644 --- a/packages/form-core/src/devtoolsBridge.lib.ts +++ b/packages/form-core/src/devtoolsBridge.lib.ts @@ -1,15 +1,30 @@ import type { AnyInternalFormApi } from './FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' +import type { + AnyInternalFieldApi, + InternalFieldValidatorInstance, +} from './FieldApi/FieldApi.lib' /** * A listener or validator dependency edge affected by reconciliation. */ -export interface FieldDependencyChange { +interface BaseFieldDependencyChange { sourceField: AnyInternalFieldApi watchingField: AnyInternalFieldApi +} + +export interface FieldListenerDependencyChange extends BaseFieldDependencyChange { + kind: 'listener' watcherIndex: number } +export interface FieldValidatorDependencyChange extends BaseFieldDependencyChange { + kind: 'validator' + validatorInstance: InternalFieldValidatorInstance +} + +export type FieldDependencyChange = + FieldListenerDependencyChange | FieldValidatorDependencyChange + /** * Optional hooks installed by `@tanstack/form-devtools`. * diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index f4303d67e8..8bdcd3addf 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -8,6 +8,8 @@ export * from './utils.lib' export * from './types.lib' export * from './FieldApi/RootFieldApi.lib' export * from './validation' +export * from './ValidatorInstance.lib' +export * from './ValidationSourceInstance.lib' export * from './listeners.lib' export * from './FieldApi/linked-fields.lib' export * from './standardSchema.lib' diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts index 8176ec9ac4..e53630fd74 100644 --- a/packages/form-core/src/listeners.lib.ts +++ b/packages/form-core/src/listeners.lib.ts @@ -115,7 +115,7 @@ function getListenerDebounceMs( } function getOrCreateDebouncer( - cache: PipelineCache, + cache: PipelineCache, cacheKey: number, fn: (context: ListenerContext) => void, wait: number, @@ -155,7 +155,7 @@ function runListener({ listener: AnyListener context: TContext listenerIndex: number - cache: PipelineCache + cache: PipelineCache getContext: (inputContext: TContext) => ListenerContext }): void { const cacheKey = listenerIndex @@ -185,7 +185,7 @@ function runListenerPipeline({ }: { pipeline: ReadonlyArray context: TContext - cache: PipelineCache + cache: PipelineCache getContext: (inputContext: TContext) => ListenerContext }): void { pipeline.forEach((listener, listenerIndex) => { diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 0cef8887dd..7768f53341 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -6,6 +6,7 @@ import { cancelPipelineCache, createPipelineCache, evaluate } from './utils.lib' import { runValidatorPipeline } from './validation' import { createErrorMap } from './validation.public' import { devtools } from './devtoolsBridge.lib' +import { reconcileValidatorInstances } from './ValidatorInstance.lib' import type { FormOptions } from './FormApi/FormApi.public' import type { FormErrorMeta } from './FormApi/formState.lib' import type { InternalFormApi } from './FormApi/FormApi.lib' @@ -25,22 +26,12 @@ type ServerFormValidateResult< ToServerFormErrorTypes > -function createInitialFormErrorMeta(validatorCount: number): FormErrorMeta { +function createInitialFormErrorMeta(): FormErrorMeta { return { - errors: Array.from({ length: validatorCount }, () => []), - errorSourceEvents: Array.from({ length: validatorCount }, () => null), + validationSourceErrors: null, } } -function createInitialFieldErrors( - validatorCount: number, -): Array> { - return Array.from( - { length: validatorCount }, - () => new Set(), - ) -} - function resetFieldMetaForServerState(form: InternalFormApi) { visitAllFormFields(form._fieldRootNode, (field) => { field._defaultValueCache = null @@ -49,6 +40,7 @@ function resetFieldMetaForServerState(form: InternalFormApi) { cancelPipelineCache(field._pipelineCache) field._pipelineCache = null } + field._validatorInstances?.forEach((instance) => instance.resetRuntime()) const metaAtom = field._atoms.meta if (metaAtom && metaAtom.get() !== defaultInternalBaseFieldMeta) { @@ -63,7 +55,6 @@ function resetToServerState( serverState: ServerFormState, defaultValues: TFormData, ): void { - const validatorCount = form._options.validators?.length ?? 0 const values = serverState.values ?? defaultValues const shouldUpdateDefaultValues = !evaluate( values, @@ -72,7 +63,8 @@ function resetToServerState( cancelPipelineCache(form._pipelineCache) form._pipelineCache = createPipelineCache() - form._schemaOutputs = [] + form._validatorInstances?.forEach((instance) => instance.resetRuntime()) + form._onSubmitSource.resetRuntime() form._defaultValueCache = null if (shouldUpdateDefaultValues) { @@ -90,8 +82,7 @@ function resetToServerState( form._atoms.values.set(values) form._atoms.meta.isDirty.set(false) form._atoms.meta.touchedFieldCount.set(0) - form._atoms.meta.formErrors.set(createInitialFormErrorMeta(validatorCount)) - form._atoms.meta.fieldErrors.set(createInitialFieldErrors(validatorCount)) + form._atoms.meta.formErrors.set(createInitialFormErrorMeta()) form._atoms.meta.errorFields.set(new Set()) form._atoms.meta.fieldValidationCount.set(0) form._atoms.meta.validationCount.set(0) @@ -100,7 +91,11 @@ function resetToServerState( form._atoms.meta.submissionAttempts.set(serverState.submissionAttempts) for (const result of serverState.validationResults) { - form._processValidationResult(result, 'server') + const validatorInstance = + form._validatorInstances?.[result.validatorIndex] + if (!validatorInstance) continue + + form._processValidationResult({ ...result, validatorInstance }, 'server') } }) } @@ -131,66 +126,82 @@ export async function validateServerValues< values: TFormData, ): Promise> { const pipeline = options.validators - const schemaOutputs: Array = Array.from( - { length: pipeline?.length ?? 0 }, - () => undefined, - ) if (!pipeline || pipeline.length === 0) { return { success: true, values, - schemaOutputs: schemaOutputs as never, + schemaOutputs: [] as never, } } - const pipelineResult = await runValidatorPipeline< - ServerFormValidateResult - >({ - pipeline, - cache: createPipelineCache(), - context: { - scope: 'server', - event: 'server', - formApi: undefined, - }, - hasFailedBefore: false, - getContext: (ctx) => ({ - event: 'server', - signal: ctx.signal, - formApi: undefined, - value: values, - createErrorMap, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, values, 'form'), - }), + const validatorInstances = reconcileValidatorInstances({ + definitions: pipeline, + instances: null, + owner: options, scope: 'form', }) - if (pipelineResult.thrownError !== null) { - throw pipelineResult.thrownError - } + try { + const pipelineResult = await runValidatorPipeline< + ServerFormValidateResult + >({ + pipeline: validatorInstances ?? [], + context: { + scope: 'server', + event: 'server', + formApi: undefined, + }, + hasFailedBefore: false, + getContext: (ctx) => ({ + event: 'server', + signal: ctx.signal, + formApi: undefined, + value: values, + createErrorMap, + parseIssues: (issues) => + parseStandardSchemaIssues(issues, values, 'form'), + }), + scope: 'form', + }) + + if (pipelineResult.thrownError !== null) { + throw pipelineResult.thrownError + } - for (const result of pipelineResult.results) { - if (result.hasSchemaResult) { - schemaOutputs[result.validatorIndex] = result.schemaResult + const schemaOutputs = + validatorInstances?.map((instance) => { + const result = pipelineResult.results.find( + (r) => r.validatorInstance === instance, + ) + return result?.hasSchemaResult ? result.schemaResult : undefined + }) ?? [] + + const validationResults = pipelineResult.results.map((result) => ({ + validatorIndex: + validatorInstances?.indexOf(result.validatorInstance) ?? -1, + result: result.result, + schemaResult: result.schemaResult, + hasSchemaResult: result.hasSchemaResult, + })) + + if (pipelineResult.hasErrors) { + return { + success: false, + serverState: { + values, + validationResults, + submissionAttempts: 1, + }, + } } - } - if (pipelineResult.hasErrors) { return { - success: false, - serverState: { - values, - validationResults: pipelineResult.results, - submissionAttempts: 1, - }, + success: true, + values, + schemaOutputs: schemaOutputs as never, } - } - - return { - success: true, - values, - schemaOutputs: schemaOutputs as never, + } finally { + validatorInstances?.forEach((instance) => instance.dispose()) } } diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index c025fc0c93..535485f5cf 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -2,12 +2,6 @@ import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' // type import type { FieldUpdateOptions, OneOrMany, Updater } from './types.public' -import type { ValidationDebouncer } from './validation' -import type { - FieldValidateResult, - FormGroupValidateResult, - FormValidateResult, -} from './validation.public' import type { ListenerDebouncer } from './listeners.lib' import type { InternalFieldUpdateOptions, @@ -94,42 +88,21 @@ export function getTargetField( return field } -export interface PipelineCache< - in out TResult extends - | FormValidateResult - | FormGroupValidateResult - | FieldValidateResult, -> { +export interface PipelineCache { listenerDebouncers: Map - validatorDebouncers: Map> - validatorAbortControllers: Map } -export function createPipelineCache(): PipelineCache { +export function createPipelineCache(): PipelineCache { return { listenerDebouncers: new Map(), - validatorDebouncers: new Map(), - validatorAbortControllers: new Map(), } } -export function cancelPipelineCache(cache: PipelineCache): void { - for (const abortController of Array.from( - cache.validatorAbortControllers.values(), - )) { - abortController.abort() - } - - for (const debouncer of cache.validatorDebouncers.values()) { - debouncer.cancel() - } - +export function cancelPipelineCache(cache: PipelineCache): void { for (const debouncer of cache.listenerDebouncers.values()) { debouncer.cancel() } - cache.validatorAbortControllers.clear() - cache.validatorDebouncers.clear() cache.listenerDebouncers.clear() } diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts index c85138b44f..4606bab024 100644 --- a/packages/form-core/src/validation/errors.lib.ts +++ b/packages/form-core/src/validation/errors.lib.ts @@ -1,4 +1,6 @@ import { evaluate, isNil, normalizeToArray } from '../utils.lib' +import { compareValidationSources } from '../ValidationSourceInstance.lib' +import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, @@ -26,8 +28,9 @@ export interface ParsedValidationResult { } /** - * @private - * Check whether a validation result is an error map. + * Checks whether a validation result is a routable form-style error map. + * + * Issue objects are excluded even though they are also object values. */ export function isValidationErrorMap( value: unknown, @@ -46,9 +49,11 @@ export function isValidationErrorMap( } /** - * @private - * Normalize a validation result into errors owned by the validation boundary + * Normalizes a validation result into errors owned by the validation boundary * and errors routed to its subfields. + * + * Empty error collections collapse to `null` so callers can distinguish them + * from errors that must be stored. */ export function parseValidationResult( value: ValidateResult, @@ -84,8 +89,7 @@ export function parseValidationResult( } /** - * @private - * Check if a validation result contains an error that would be stored. + * Checks whether a validation result contains an error that would be stored. */ export function isErrorResult( value: T, @@ -97,108 +101,127 @@ export function isErrorResult( ) } -export function hasIndexedErrorFromSource( - errors: Array>, - errorSourceEvents: Array, - index: number, - sourceEvent: string, -): boolean { - const error = errors[index] - if (!error) return false - if (error.length === 0) return false - if (errorSourceEvents[index] !== sourceEvent) return false - return true +export interface ValidationSourceErrorState { + errors: Array + sourceEvent: string } -export function hasIndexedErrors( - errors: Array>, +export type ValidationSourceErrorMap = Map< + AnyInternalValidationSourceInstance, + ValidationSourceErrorState +> + +/** Checks whether a source's stored errors came from a specific event. */ +export function hasValidationSourceErrorFromEvent( + errorMap: ValidationSourceErrorMap | null, + validationSource: AnyInternalValidationSourceInstance, + sourceEvent: string, ): boolean { - return errors.some((validatorErrors) => validatorErrors.length > 0) + return errorMap?.get(validationSource)?.sourceEvent === sourceEvent } -export function setIndexedError( - errors: Array>, - errorSourceEvents: Array, - index: number, - error: Array, +/** + * Applies one validation source's errors with copy-on-write map semantics. + * + * Empty errors remove the source. Returns `null` when the stored value and + * source event already match, allowing atom owners to preserve identity. + */ +export function setValidationSourceError( + errorMap: ValidationSourceErrorMap | null, + validationSource: AnyInternalValidationSourceInstance, + errors: Array, sourceEvent: string, -): { - errors: Array> - errorSourceEvents: Array -} | null { - const nextSourceEvent = error.length > 0 ? sourceEvent : null - const prevError = errors[index] ?? [] - +): { errorMap: ValidationSourceErrorMap | null } | null { + const previous = errorMap?.get(validationSource) if ( - evaluate(prevError, error) && - errorSourceEvents[index] === nextSourceEvent + previous && + evaluate(previous.errors, errors) && + previous.sourceEvent === sourceEvent ) { return null } + if (!previous && errors.length === 0) return null - const nextLength = Math.max( - errors.length, - errorSourceEvents.length, - index + 1, - ) - const nextErrors = Array.from( - { length: nextLength }, - (_, errorIndex) => errors[errorIndex] ?? [], - ) - const nextErrorSourceEvents = Array.from( - { length: nextLength }, - (_, errorIndex) => errorSourceEvents[errorIndex] ?? null, - ) - nextErrors[index] = error - nextErrorSourceEvents[index] = nextSourceEvent - - return { - errors: nextErrors, - errorSourceEvents: nextErrorSourceEvents, + const next = errorMap ? new Map(errorMap) : new Map() + if (errors.length > 0) { + next.set(validationSource, { errors, sourceEvent }) + } else { + next.delete(validationSource) } + + return { errorMap: next.size > 0 ? next : null } } -export function clearIndexedErrorsFromSource( - errors: Array>, - errorSourceEvents: Array, - indexes: Array, +/** + * Removes errors from selected sources only when their source event matches. + * + * The map is cloned lazily and `null` is returned when no entry changes. + */ +export function clearValidationSourceErrorsFromEvent( + errorMap: ValidationSourceErrorMap | null, + validationSources: Iterable, sourceEvent: string, -): { - errors: Array> - errorSourceEvents: Array -} | null { - let nextErrors: Array> | null = null - let nextErrorSourceEvents: Array | null = null +): { errorMap: ValidationSourceErrorMap | null } | null { + if (!errorMap) return null - for (const index of indexes) { + let next: ValidationSourceErrorMap | null = null + for (const validationSource of validationSources) { if ( - hasIndexedErrorFromSource(errors, errorSourceEvents, index, sourceEvent) + !hasValidationSourceErrorFromEvent( + errorMap, + validationSource, + sourceEvent, + ) ) { - nextErrors ??= errors.slice() - nextErrorSourceEvents ??= errorSourceEvents.slice() - nextErrors[index] = [] - nextErrorSourceEvents[index] = null + continue + } + + if (!next) { + next = new Map(errorMap) } + next.delete(validationSource) } - if (!nextErrors || !nextErrorSourceEvents) return null + if (!next) return null + return { errorMap: next.size > 0 ? next : null } +} - return { - errors: nextErrors, - errorSourceEvents: nextErrorSourceEvents, - } +/** + * Flattens stored issues by scope priority and then pipeline position. + */ +export function getValidationSourceErrors( + errorMap: ValidationSourceErrorMap | null, + validationSources?: ReadonlyArray | null, +): Array { + if (!errorMap) return [] + + const sources = Array.from(validationSources ?? errorMap.keys()).sort( + compareValidationSources, + ) + return sources.flatMap( + (validationSource) => errorMap.get(validationSource)?.errors ?? [], + ) } +/** + * Reconciles the fields currently receiving routed errors from one source. + * + * Current targets are written, stale targets are cleared, and both the next + * target set and all affected fields are returned to the owner. + */ export function reconcileRoutedFieldErrors( - validatorIndex: number, + validationSource: AnyInternalValidationSourceInstance, fieldErrors: Iterable]>, oldFieldRefs: Set | undefined, setFieldError: ( field: AnyInternalFieldApi, - validatorIndex: number, + validationSource: AnyInternalValidationSourceInstance, errors: Array, ) => void, - clearFieldError: (field: AnyInternalFieldApi, validatorIndex: number) => void, + clearFieldError: ( + field: AnyInternalFieldApi, + validationSource: AnyInternalValidationSourceInstance, + ) => void, ): { fieldRefs: Set affectedFields: Set @@ -209,7 +232,7 @@ export function reconcileRoutedFieldErrors( const newFieldRefs = new Set() for (const [field, fieldError] of fieldErrors) { - setFieldError(field, validatorIndex, fieldError) + setFieldError(field, validationSource, fieldError) newFieldRefs.add(field) affectedFields.add(field) staleFieldRefs?.delete(field) @@ -217,16 +240,18 @@ export function reconcileRoutedFieldErrors( if (staleFieldRefs) { for (const field of staleFieldRefs) { - clearFieldError(field, validatorIndex) + clearFieldError(field, validationSource) affectedFields.add(field) } } + const didFieldRefsChange = + newFieldRefs.size > 0 || + (oldFieldRefs !== undefined && oldFieldRefs.size > 0) + return { fieldRefs: newFieldRefs, affectedFields, - didFieldRefsChange: - newFieldRefs.size > 0 || - (oldFieldRefs !== undefined && oldFieldRefs.size > 0), + didFieldRefsChange, } } diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index d2d5d7765d..150d0c4aeb 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,10 +1,11 @@ -import { LiteDebouncer } from '@tanstack/pacer-lite' import { isStandardSchema, parseStandardSchema, parseStandardSchemaIssues, } from '../standardSchema.lib' -import type { PipelineCache } from '../utils.lib' +import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' +import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, FieldValidator, @@ -20,8 +21,6 @@ import type { ValidationTriggerOption, Validator, } from '../validation.public' -import type { InternalFormApi } from '../FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' type FormValidateContext = { scope: 'form' @@ -61,7 +60,7 @@ export type ValidateContext = | FormGroupValidateContext export type ValidateResult = FormValidateResult | FormGroupValidateResult | FieldValidateResult -export type AnyPipelineValidator = +type AnyPipelineValidator = | FormValidator | FormGroupValidator | FieldValidator @@ -76,36 +75,48 @@ function isServerContext(ctx: InputContext): ctx is ServerFormInputContext { return ctx.event === 'server' } +/** Narrows an input context to a field-owned validation pipeline. */ function isFieldContext(ctx: InputContext): ctx is FieldInputContext { return ctx.scope === 'field' } +/** Narrows an input context to a form-group-owned validation pipeline. */ function isGroupContext(ctx: InputContext): ctx is FormGroupInputContext { return ctx.scope === 'group' } +/** Narrows an executing context to the server pipeline variant. */ export function isServerValidateContext( ctx: ValidateContext, ): ctx is ServerPipelineValidateContext { return ctx.event === 'server' } +/** Narrows an executing context to the field pipeline variant. */ export function isFieldValidateContext( ctx: ValidateContext, ): ctx is FieldValidateContext { return ctx.scope === 'field' } +/** Checks whether a configured trigger is the server-only trigger. */ function isServerTrigger( trigger: ValidationTriggerOption | 'server', ): boolean { return trigger === 'server' } +/** Checks whether a validator participates in server validation. */ function hasServerTrigger(validator: AnyPipelineValidator): boolean { return validator.triggers.some(isServerTrigger) } +/** + * Creates the scope-specific context passed to predicate and debounce callbacks. + * + * Values are read when the callback is evaluated so it observes the owning + * form, group, or field's current state. + */ function getPredicateContext( context: Exclude, ): ValidationPredicateContext { @@ -136,6 +147,7 @@ function getPredicateContext( } } +/** Parses Standard Schema issues as errors owned directly by a field. */ export function parseFieldIssues( issues: Parameters['parseIssues']>[0], ) { @@ -147,7 +159,6 @@ export const THROWN_ERROR = Symbol('THROWN_ERROR') export type AbortedCall = typeof ABORTED_CALL export type ThrownError = { [THROWN_ERROR]: true; error: unknown } - export interface ValidatorExecutionResult { result: TResult schemaResult: any | null @@ -162,9 +173,8 @@ interface PendingDebouncedCall { reject: (error: unknown) => void } -export type ValidationDebouncer = LiteDebouncer< - (call: PendingDebouncedCall) => void -> +type PipelineValidatorInstance = + AnyInternalValidatorInstance<(call: PendingDebouncedCall) => void> function getEnabledState( booleanOrFn: boolean | ((context: any) => boolean), @@ -176,6 +186,7 @@ function getEnabledState( return booleanOrFn(getPredicateContext(context)) } +/** Resolves a static delay or evaluates its callback outside server context. */ function getDebounceMs( numberOrFn: number | ((context: any) => number), context: InputContext, @@ -186,6 +197,12 @@ function getDebounceMs( return numberOrFn(getPredicateContext(context)) } +/** + * Checks whether a trigger matches the current event and passes its condition. + * + * String triggers match directly. Object triggers additionally evaluate their + * `when` predicate, which defaults to enabled. + */ export function isValidationTriggerEnabled( trigger: ValidationTriggerOption | 'server', context: InputContext, @@ -203,6 +220,7 @@ export function isValidationTriggerEnabled( return getEnabledState(enabled, context) } +/** Selects a validator using server, submit, or configured event semantics. */ export function shouldRunValidator( validator: AnyPipelineValidator, context: InputContext, @@ -222,6 +240,12 @@ export function shouldRunValidator( ) } +/** + * Executes a validator and normalizes its result for pipeline processing. + * + * Standard Schema validators retain their parsed output and presence marker; + * function validators produce only a validation result. + */ export async function executeValidator( validator: AnyPipelineValidator, context: AnyValidatorContext, @@ -241,25 +265,18 @@ export async function executeValidator( interface RunMaybeDebouncedValidatorArgs< in out TResult extends ValidateResult, > { - validator: AnyPipelineValidator + validatorInstance: PipelineValidatorInstance context: InputContext - validatorIndex: number - cache: PipelineCache onExecute: ( inputContext: ValidateContext, ) => Promise> } -function clearAbortController( - cache: PipelineCache, - cacheKey: number, - abortController: AbortController, -): void { - if (cache.validatorAbortControllers.get(cacheKey) === abortController) { - cache.validatorAbortControllers.delete(cacheKey) - } -} - +/** + * Creates a promise that resolves with the internal sentinel when aborted. + * + * Call `cleanup` after the race settles to release the signal listener. + */ function createAbortPromise(signal: AbortSignal): { promise: Promise cleanup: () => void @@ -288,6 +305,12 @@ function createAbortPromise(signal: AbortSignal): { } } +/** + * Races validator execution against its abort signal. + * + * Abortion suppresses the eventual execution result without requiring the + * underlying validator promise to support cancellation. + */ async function executeWithAbort( context: ValidateContext, onExecute: ( @@ -310,6 +333,7 @@ async function executeWithAbort( } } +/** Resolves debounce duration, forcing immediate submit and server execution. */ function getValidatorDebounceMs( validator: AnyPipelineValidator, context: InputContext, @@ -321,80 +345,55 @@ function getValidatorDebounceMs( return getDebounceMs(triggerDebounceMs, context) } -function abortPreviousValidatorRun( - cache: PipelineCache, - cacheKey: number, -): void { - // AbortControllers are scoped to the validator instead of the whole pipeline. - // Mostly because different validators can have different debounces and they - // can be triggered by unrelated validation signals - cache.validatorAbortControllers.get(cacheKey)?.abort() -} - -export function createValidatorAbortContext( - cache: PipelineCache, - cacheKey: number, +/** + * Installs the next abort controller on a stable validator instance. + * + * Installation aborts any previous execution. The returned cleanup clears the + * controller only if it is still the instance's active controller. + */ +export function createValidatorAbortContext( + validatorInstance: AnyInternalValidatorInstance, opts?: { cancelDebouncer?: boolean }, ): { abortController: AbortController signal: AbortSignal cleanup: () => void } { - abortPreviousValidatorRun(cache, cacheKey) - if (opts?.cancelDebouncer) { - cache.validatorDebouncers.get(cacheKey)?.cancel() + validatorInstance.debouncer?.cancel() } const abortController = new AbortController() const signal = abortController.signal - cache.validatorAbortControllers.set(cacheKey, abortController) + validatorInstance.setAbortController(abortController) return { abortController, signal, cleanup: () => { - clearAbortController(cache, cacheKey, abortController) + validatorInstance.clearAbortController(abortController) }, } } -function getOrCreateDebouncer( - cache: PipelineCache, - cacheKey: number, - fn: (call: PendingDebouncedCall) => void, - wait: number, -): ValidationDebouncer { - let debouncer = cache.validatorDebouncers.get(cacheKey) - - if (!debouncer) { - debouncer = new LiteDebouncer(fn, { - wait, - }) - - cache.validatorDebouncers.set(cacheKey, debouncer) - } else { - debouncer.fn = fn - debouncer.options.wait = wait - } - - return debouncer -} - +/** + * Runs one validator immediately or through its instance-owned debouncer. + * + * Abort and thrown-error sentinels keep stale or exceptional executions out of + * normal result processing. Every settlement releases its abort resources. + */ export function runMaybeDebouncedValidator({ - validator, + validatorInstance, context, - validatorIndex, - cache, onExecute, }: RunMaybeDebouncedValidatorArgs): Promise< ValidatorExecutionResult | AbortedCall | ThrownError > { - const cacheKey = validatorIndex + const validator = validatorInstance.definition const debounceMs = getValidatorDebounceMs(validator, context) - const { signal, cleanup } = createValidatorAbortContext(cache, cacheKey) + const { signal, cleanup } = createValidatorAbortContext(validatorInstance) const validationContext: ValidateContext = { ...context, @@ -425,7 +424,7 @@ export function runMaybeDebouncedValidator({ } const onAbort = () => { - cache.validatorDebouncers.get(cacheKey)?.cancel() + validatorInstance.debouncer?.cancel() settle(ABORTED_CALL) } @@ -440,26 +439,23 @@ export function runMaybeDebouncedValidator({ } if (debounceMs <= 0) { - cache.validatorDebouncers.get(cacheKey)?.cancel() + validatorInstance.debouncer?.cancel() run(validationContext) return } - const debouncer = getOrCreateDebouncer( - cache, - cacheKey, - (call) => { - executeWithAbort(call.context, onExecute).then( - call.resolve, - (error) => { - console.error('Validator threw an error:', error) - const thrownError: ThrownError = { [THROWN_ERROR]: true, error } - call.resolve(thrownError) - }, - ) - }, - debounceMs, - ) + const debouncer = validatorInstance.getOrCreateDebouncer((call) => { + executeWithAbort(call.context, onExecute).then(call.resolve, (error) => { + console.error('Validator threw an error:', error) + const thrownError: ThrownError = { [THROWN_ERROR]: true, error } + call.resolve(thrownError) + }) + }, debounceMs) + + if (!debouncer) { + settle(ABORTED_CALL) + return + } debouncer.maybeExecute({ context: validationContext, diff --git a/packages/form-core/src/validation/index.ts b/packages/form-core/src/validation/index.ts index eb82a015be..f8c5e0b5a6 100644 --- a/packages/form-core/src/validation/index.ts +++ b/packages/form-core/src/validation/index.ts @@ -1,21 +1,21 @@ export { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, - hasIndexedErrors, + clearValidationSourceErrorsFromEvent, + getValidationSourceErrors, + hasValidationSourceErrorFromEvent, isErrorResult, isValidationErrorMap, normalizeValidationError, parseValidationResult, reconcileRoutedFieldErrors, - setIndexedError, + setValidationSourceError, } from './errors.lib' -export type { ParsedValidationResult } from './errors.lib' -export { isValidationTriggerEnabled } from './execution.lib' export type { - InputContext, - ValidateContext, - ValidationDebouncer, -} from './execution.lib' + ParsedValidationResult, + ValidationSourceErrorMap, + ValidationSourceErrorState, +} from './errors.lib' +export { isValidationTriggerEnabled } from './execution.lib' +export type { InputContext, ValidateContext } from './execution.lib' export { runFieldValidatorPipeline, runFormValidatorPipeline, diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index e2526c39ee..419e555e9b 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -7,23 +7,16 @@ import { import { isPromiseLike } from '../utils.lib' import { isErrorResult } from './errors.lib' import { createValidatorAbortContext, parseFieldIssues } from './execution.lib' -import type { PipelineCache } from '../utils.lib' +import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' +import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' +import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { FieldValidateResult, - FieldValidator, FormGroupValidateResult, - FormGroupValidator, FormValidateResult, - FormValidator, } from '../validation.public' -import type { InternalFormApi } from '../FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' -import type { - AnyPipelineValidator, - AnyValidatorContext, - ValidateResult, -} from './execution.lib' +import type { AnyValidatorContext, ValidateResult } from './execution.lib' import type { PipelineResult } from './pipeline.lib' type MountValidationExecutionResult = { @@ -33,7 +26,7 @@ type MountValidationExecutionResult = { } interface FormMountValidatorPipelineArgs { - pipeline: ReadonlyArray> + pipeline: ReadonlyArray formApi: InternalFormApi onResult?: (result: PipelineResult>) => void } @@ -44,13 +37,15 @@ export interface FormMountValidatorPipelineResult { } interface MountValidatorPipelineArgs { - pipeline: ReadonlyArray - cache: PipelineCache + pipeline: ReadonlyArray getContext: (signal: AbortSignal) => AnyValidatorContext scope: 'field' | 'form' onResult?: (result: PipelineResult) => void } +/** + * Creates the neutral result used when mount validation is aborted or throws. + */ function createEmptyMountValidationResult< TResult extends ValidateResult, >(): MountValidationExecutionResult { @@ -61,13 +56,19 @@ function createEmptyMountValidationResult< } } +/** + * Publishes one mount execution result and reports whether it contains errors. + * + * Mount schema outputs remain on the immediate result and are not persisted on + * the validator instance. + */ function processMountValidationExecutionResult( - validatorIndex: number, + validatorInstance: AnyInternalValidatorInstance, executionResult: MountValidationExecutionResult, onResult?: (result: PipelineResult) => void, ): boolean { const result: PipelineResult = { - validatorIndex, + validatorInstance, result: executionResult.result, schemaResult: executionResult.schemaResult, hasSchemaResult: executionResult.hasSchemaResult, @@ -78,20 +79,23 @@ function processMountValidationExecutionResult( return isErrorResult(executionResult.result) } +/** + * Executes one mount validator immediately without trigger debounce. + * + * Aborted async results and thrown validators become neutral mount results; + * thrown errors are logged after execution resources are released. + */ function executeMountValidator( - cache: PipelineCache, getContext: MountValidatorPipelineArgs['getContext'], scope: 'field' | 'form', - validator: AnyPipelineValidator, - validatorIndex: number, + validatorInstance: AnyInternalValidatorInstance, ): | MountValidationExecutionResult | PromiseLike> { - const { signal, cleanup } = createValidatorAbortContext( - cache, - validatorIndex, - { cancelDebouncer: true }, - ) + const validator = validatorInstance.definition + const { signal, cleanup } = createValidatorAbortContext(validatorInstance, { + cancelDebouncer: true, + }) const context = getContext(signal) @@ -105,6 +109,10 @@ function executeMountValidator( return result }) + .catch((error) => { + console.error(error) + return createEmptyMountValidationResult() + }) .finally(cleanup) as unknown as PromiseLike< MountValidationExecutionResult > @@ -125,6 +133,10 @@ function executeMountValidator( hasSchemaResult: false, } }) + .catch((error) => { + console.error(error) + return createEmptyMountValidationResult() + }) .finally(cleanup) } @@ -141,11 +153,16 @@ function executeMountValidator( } } +/** + * Continues mount validation sequentially after the first asynchronous result. + * + * Later validators still honor `runOnMount` and `bailIfInvalid` in configured + * order. + */ async function continueMountValidationFromAsyncResult< TResult extends ValidateResult, >( - pipeline: ReadonlyArray, - cache: PipelineCache, + pipeline: ReadonlyArray, getContext: MountValidatorPipelineArgs['getContext'], scope: 'field' | 'form', startIndex: number, @@ -158,7 +175,7 @@ async function continueMountValidationFromAsyncResult< const firstExecutionResult = await firstResult if ( processMountValidationExecutionResult( - startIndex, + pipeline[startIndex]!, firstExecutionResult, onResult, ) @@ -167,29 +184,39 @@ async function continueMountValidationFromAsyncResult< } for (let i = startIndex + 1; i < pipeline.length; i++) { - const validator = pipeline[i]! + const validatorInstance = pipeline[i]! + const validator = validatorInstance.definition if (validator.runOnMount !== true) continue if (validator.bailIfInvalid && hasFailed) break const result = executeMountValidator( - cache, getContext, scope, - validator, - i, + validatorInstance, ) const executionResult = isPromiseLike(result) ? await result : result - if (processMountValidationExecutionResult(i, executionResult, onResult)) { + if ( + processMountValidationExecutionResult( + validatorInstance, + executionResult, + onResult, + ) + ) { hasFailed = true } } } +/** + * Starts all eligible synchronous mount work and exposes async continuation. + * + * The pipeline returns synchronously until it encounters its first promise; + * remaining eligible validators then continue through `asyncPromise`. + */ function runMountValidatorPipeline({ pipeline, - cache, getContext, scope, onResult, @@ -200,7 +227,11 @@ function runMountValidatorPipeline({ asyncPromise: null, } - if (!pipeline.some((validator) => validator.runOnMount === true)) + if ( + !pipeline.some( + (validatorInstance) => validatorInstance.definition.runOnMount === true, + ) + ) return { didRun: false, asyncPromise: null, @@ -209,7 +240,8 @@ function runMountValidatorPipeline({ let hasFailed = false for (let i = 0; i < pipeline.length; i++) { - const validator = pipeline[i]! + const validatorInstance = pipeline[i]! + const validator = validatorInstance.definition if (validator.runOnMount !== true) continue if (validator.bailIfInvalid && hasFailed) { @@ -220,11 +252,9 @@ function runMountValidatorPipeline({ } const result = executeMountValidator( - cache, getContext, scope, - validator, - i, + validatorInstance, ) if (isPromiseLike(result)) { @@ -232,7 +262,6 @@ function runMountValidatorPipeline({ didRun: true, asyncPromise: continueMountValidationFromAsyncResult( pipeline, - cache, getContext, scope, i, @@ -243,7 +272,9 @@ function runMountValidatorPipeline({ } } - if (processMountValidationExecutionResult(i, result, onResult)) { + if ( + processMountValidationExecutionResult(validatorInstance, result, onResult) + ) { hasFailed = true } } @@ -254,6 +285,7 @@ function runMountValidatorPipeline({ } } +/** Runs mount validation with form-scoped values and routed issue parsing. */ export function runFormMountValidatorPipeline({ pipeline, formApi, @@ -261,7 +293,6 @@ export function runFormMountValidatorPipeline({ }: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline>({ pipeline, - cache: formApi._pipelineCache, getContext: (signal) => ({ event: 'mount' as never, signal, @@ -277,11 +308,12 @@ export function runFormMountValidatorPipeline({ } interface FieldMountValidatorPipelineArgs { - pipeline: ReadonlyArray> + pipeline: ReadonlyArray fieldApi: AnyInternalFieldApi onResult?: (result: PipelineResult) => void } +/** Runs mount validation with the field's current value and issue parser. */ export function runFieldMountValidatorPipeline({ pipeline, fieldApi, @@ -289,7 +321,6 @@ export function runFieldMountValidatorPipeline({ }: FieldMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline({ pipeline, - cache: fieldApi._getOrCreatePipelineCache(), getContext: (signal) => ({ event: 'mount' as never, signal, @@ -306,11 +337,12 @@ export function runFieldMountValidatorPipeline({ // ===== GROUP MOUNT VALIDATION ===== interface GroupMountValidatorPipelineArgs { - pipeline: ReadonlyArray> + pipeline: ReadonlyArray groupApi: AnyInternalFormGroupApi onResult?: (result: PipelineResult>) => void } +/** Runs mount validation with group-scoped values and routed issue parsing. */ export function runGroupMountValidatorPipeline({ pipeline, groupApi, @@ -318,7 +350,6 @@ export function runGroupMountValidatorPipeline({ }: GroupMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline>({ pipeline, - cache: groupApi._pipelineCache, getContext: (signal) => ({ event: 'mount' as never, signal, diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index d46fce94cd..9ba77b800b 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -12,17 +12,14 @@ import { runMaybeDebouncedValidator, shouldRunValidator, } from './execution.lib' -import type { PipelineCache } from '../utils.lib' +import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, - FieldValidator, FormValidateResult, - FormValidator, } from '../validation.public' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { AbortedCall, - AnyPipelineValidator, AnyValidatorContext, FieldInputContext, FormInputContext, @@ -34,25 +31,24 @@ import type { } from './execution.lib' export interface PipelineResult { - validatorIndex: number + validatorInstance: AnyInternalValidatorInstance result: T schemaResult: any | null hasSchemaResult?: boolean } interface PendingPipelineResult { - validatorIndex: number + validatorInstance: AnyInternalValidatorInstance result: T } interface ValidatorPipelineArgs { context: InputContext - cache: PipelineCache - pipeline: ReadonlyArray + pipeline: ReadonlyArray hasFailedBefore: boolean getContext: (inputContext: ValidateContext) => AnyValidatorContext scope: 'field' | 'form' - validatorIndecesToRun?: Array | null + validatorInstancesToRun?: ReadonlySet | null onResult?: (result: PipelineResult) => void } @@ -64,9 +60,16 @@ type PendingPromises = Array< > > +/** + * Accepts a batch of pending results into the pipeline's instance-keyed map. + * + * Aborted and thrown results are excluded. Submit schema output is committed + * before `onResult` observes the accepted result. + */ async function flushPendingResults( pending: PendingPromises, - results: Array>, + results: Map>, + shouldCommitSchemaOutput: boolean, onResult?: (result: PipelineResult) => void, ): Promise<{ hasErrors: boolean; thrownError: unknown | null }> { let hasErrors = false @@ -97,13 +100,16 @@ async function flushPendingResults( } const publicResult: PipelineResult = { - validatorIndex: result.validatorIndex, + validatorInstance: result.validatorInstance, result: executionResult.result, schemaResult: executionResult.schemaResult, hasSchemaResult: executionResult.hasSchemaResult, } - results[result.validatorIndex] = publicResult + if (shouldCommitSchemaOutput) { + result.validatorInstance.setSchemaOutput(executionResult) + } + results.set(result.validatorInstance, publicResult) onResult?.(publicResult) }), ) @@ -111,29 +117,52 @@ async function flushPendingResults( return { hasErrors, thrownError } } +/** + * Runs eligible validator instances while preserving configured result order. + * + * Pending validators are flushed before `bailIfInvalid` decisions. Form and + * group submit pipelines first cancel prior executions and clear prior schema + * outputs so skipped validators cannot expose stale submit data. + */ export async function runValidatorPipeline({ pipeline, context, - cache, hasFailedBefore = false, getContext, onResult, scope, - validatorIndecesToRun = null, + validatorInstancesToRun = null, }: ValidatorPipelineArgs): Promise<{ results: Array> hasErrors: boolean thrownError: unknown | null }> { let pending: PendingPromises = [] - const results: Array> = [] + const results = new Map< + AnyInternalValidatorInstance, + PipelineResult + >() + const shouldCommitSchemaOutput = + context.event === 'submit' && context.scope !== 'field' + + if (shouldCommitSchemaOutput) { + pipeline.forEach((validatorInstance) => { + validatorInstance.cancelExecution() + validatorInstance.clearSchemaOutput() + }) + } let hasErrors = hasFailedBefore let thrownError: unknown | null = null const flush = async (): Promise => { const { hasErrors: didError, thrownError: flushedThrownError } = - await flushPendingResults(pending, results, onResult) + await flushPendingResults( + pending, + results, + shouldCommitSchemaOutput, + onResult, + ) pending = [] hasErrors ||= didError @@ -142,10 +171,13 @@ export async function runValidatorPipeline({ } } - for (let i = 0; i < pipeline.length; i++) { - const validator = pipeline[i]! + for (const validatorInstance of pipeline) { + const validator = validatorInstance.definition - if (validatorIndecesToRun && !validatorIndecesToRun.includes(i)) { + if ( + validatorInstancesToRun && + !validatorInstancesToRun.has(validatorInstance) + ) { continue } @@ -162,10 +194,8 @@ export async function runValidatorPipeline({ } const promise = runMaybeDebouncedValidator({ - validator, + validatorInstance, context, - validatorIndex: i, - cache, onExecute: (ctx) => { return executeValidator(validator, getContext(ctx), scope) }, @@ -174,7 +204,7 @@ export async function runValidatorPipeline({ ValidatorExecutionResult | AbortedCall | ThrownError > >((result) => ({ - validatorIndex: i, + validatorInstance, result, })) @@ -184,15 +214,17 @@ export async function runValidatorPipeline({ await flush() return { - // Shouldn't happen, but in case we have sparse arrays - results: results.filter(Boolean), + results: pipeline.flatMap((validatorInstance) => { + const result = results.get(validatorInstance) + return result ? [result] : [] + }), hasErrors, thrownError, } } interface FormValidatorPipelineArgs { - pipeline: ReadonlyArray> + pipeline: ReadonlyArray context: FormInputContext /** * @private @@ -208,46 +240,45 @@ export interface FormValidatorPipelineResult { thrownError: unknown | null } +/** Runs the shared pipeline with form-scoped values and issue parsing. */ export function runFormValidatorPipeline({ pipeline, context, onResult, hasFailedBefore, }: FormValidatorPipelineArgs): Promise { - const cache = context.formApi._pipelineCache - return runValidatorPipeline>({ pipeline, context, onResult, - cache, hasFailedBefore, getContext: (ctx) => { if (isServerValidateContext(ctx)) { throw new Error('Server validation cannot run through client pipeline') } + const formValidationContext = { + event: ctx.event, + formApi: ctx.formApi, + signal: ctx.signal, + value: ctx.formApi.state.values, + createErrorMap, + parseIssues: ( + issues: Parameters[0], + ) => + parseStandardSchemaIssues(issues, ctx.formApi.state.values, 'form'), + } + if (!isFieldValidateContext(ctx)) { return { - event: ctx.event, + ...formValidationContext, triggerFieldApi: ctx.triggerFieldApi, - formApi: ctx.formApi, - signal: ctx.signal, - value: ctx.formApi.state.values, - createErrorMap, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, ctx.formApi.state.values, 'form'), } } + return { - event: ctx.event, + ...formValidationContext, fieldApi: ctx.fieldApi, - formApi: ctx.formApi, - signal: ctx.signal, - value: ctx.formApi.state.values, - createErrorMap, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, ctx.formApi.state.values, 'form'), } }, scope: 'form', @@ -255,7 +286,7 @@ export function runFormValidatorPipeline({ } interface FieldValidatorPipelineArgs { - pipeline: Array> + pipeline: ReadonlyArray context: FieldInputContext onResult?: (result: PipelineResult) => void /** @@ -263,7 +294,7 @@ interface FieldValidatorPipelineArgs { * When an incoming watched field notifies, we should only run validators * that are actually interested in it. */ - validatorIndecesToRun?: Array | null + validatorInstancesToRun?: ReadonlySet | null } export interface FieldValidatorPipelineResult { @@ -272,11 +303,16 @@ export interface FieldValidatorPipelineResult { thrownError: unknown | null } +/** + * Runs the shared pipeline with field-scoped values and issue parsing. + * + * Killed fields resolve to an empty result without executing validators. + */ export function runFieldValidatorPipeline({ pipeline, context, onResult, - validatorIndecesToRun = null, + validatorInstancesToRun = null, }: FieldValidatorPipelineArgs): Promise { const fieldApi = context.fieldApi as AnyInternalFieldApi @@ -287,13 +323,10 @@ export function runFieldValidatorPipeline({ thrownError: null, }) - const cache = fieldApi._getOrCreatePipelineCache() - return runValidatorPipeline({ pipeline, context, onResult, - cache, // No use case for configuring this outside of field pipeline yet hasFailedBefore: false, getContext: (ctx) => { @@ -311,6 +344,6 @@ export function runFieldValidatorPipeline({ } }, scope: 'field', - validatorIndecesToRun, + validatorInstancesToRun, }) } diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 65a1005904..9c56c83647 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -5,6 +5,7 @@ import { } from '../../src/FieldApi/fieldState.lib' import { canPruneField } from '../../src/FieldApi/fieldTree.lib' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' describe('field - lifecycle', () => { @@ -31,6 +32,65 @@ describe('field - lifecycle', () => { }) }) + describe('validator instances', () => { + it('keeps instances stable by slot and distinguishes omitted validators from an empty array', () => { + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const firstDefinition = { run: () => null, triggers: [] } + const field = form._getOrCreateFieldApi({ + name: 'x', + validators: [firstDefinition], + }) + const instance = field._validatorInstances?.[0] + const initialRevision = instance?.revision + const nextDefinition = { run: () => null, triggers: [] } + + field._update({ validators: [nextDefinition] }) + + expect(field._validatorInstances?.[0]).toBe(instance) + expect(instance?.definition).toBe(nextDefinition) + expect(instance?.owner).toBe(field) + expect(instance?.scope).toBe(validationSourceScopes.field) + expect(instance?.revision).toBe((initialRevision ?? 0) + 1) + + field._update({}) + expect(field._validatorInstances?.[0]).toBe(instance) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + field._update({ validators: [] }) + expect(field._validatorInstances).toBeNull() + expect(instance?.disposed).toBe(true) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('resets runtime on field reset and disposes instances on kill', () => { + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'x', + validators: [{ run: () => null, triggers: [] }], + }) + const instance = field._validatorInstances?.[0] + const abortController = new AbortController() + instance?.setAbortController(abortController) + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + + field.reset() + + expect(field._validatorInstances?.[0]).toBe(instance) + expect(abortController.signal.aborted).toBe(true) + expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.disposed).toBe(false) + + field._kill() + + expect(field._validatorInstances).toBeNull() + expect(instance?.disposed).toBe(true) + }) + }) + describe('devtools bridge notifications', () => { it('notifies field mount and final unmount transitions only', () => { const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -199,14 +259,24 @@ describe('field - lifecycle', () => { }) expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { sourceField: source, watchingField: target, watcherIndex: 0 }, + { + kind: 'listener', + sourceField: source, + watchingField: target, + watcherIndex: 0, + }, ]) fieldDependenciesChanged.mockClear() target._kill() expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { sourceField: source, watchingField: target, watcherIndex: 0 }, + { + kind: 'listener', + sourceField: source, + watchingField: target, + watcherIndex: 0, + }, ]) expect(source._watchingFields).toBeNull() } finally { @@ -278,12 +348,16 @@ describe('field - lifecycle', () => { it('ignores validation results that arrive after a field is killed', () => { const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) + const field = form._getOrCreateFieldApi({ + name: 'x', + validators: [{ run: () => null, triggers: [] }], + }) + const validatorInstance = field._validatorInstances![0]! field._kill() field._processValidationResult( { - validatorIndex: 0, + validatorInstance, result: { message: 'Too late' }, schemaResult: null, }, @@ -404,7 +478,23 @@ describe('field - lifecycle', () => { field._kill() expect(form.state.canSubmit).toBe(true) - expect(form._atoms.meta.fieldErrors.get()[0]?.size).toBe(0) + expect(form._validatorInstances?.[0]?.errorTargets).toBeNull() + }) + + it('removes killed fields from onSubmit routed error bookkeeping', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError({ fields: { name: 'Name is required' } }), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form._onSubmitSource.errorTargets).toEqual(new Set([field])) + + field._kill() + + expect(form._onSubmitSource.errorTargets).toBeNull() }) it('preserves routed errors for fields outside the killed subtree', async () => { diff --git a/packages/form-core/tests/FieldApi/meta.spec.ts b/packages/form-core/tests/FieldApi/meta.spec.ts index 8e3aa68351..23e4369673 100644 --- a/packages/form-core/tests/FieldApi/meta.spec.ts +++ b/packages/form-core/tests/FieldApi/meta.spec.ts @@ -1,6 +1,32 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { InternalFormGroupApi } from '../../src/FormGroupApi/FormGroupApi.lib' import { defaultFieldMeta } from '../../src/FieldApi/fieldState.lib' +import { reconcileValidatorInstances } from '../../src/ValidatorInstance.lib' +import type { AnyInternalFieldApi } from '../../src/FieldApi/FieldApi.lib' +import type { ValidationIssue } from '../../src/validation.public' +import type { ValidationSourceErrorMap } from '../../src/validation' + +function setFieldValidatorErrors( + field: AnyInternalFieldApi, + errors: Array, +): void { + field._validatorInstances ??= reconcileValidatorInstances({ + definitions: [{ run: () => null, triggers: [] }], + instances: null, + owner: field, + scope: 'field', + }) + const validatorInstance = field._validatorInstances![0]! + + field._setMeta((prev) => ({ + ...prev, + _validationSourceErrors: + errors.length > 0 + ? new Map([[validatorInstance, { errors, sourceEvent: 'test' }]]) + : null, + })) +} describe('field - meta', () => { afterEach(() => { @@ -8,6 +34,82 @@ describe('field - meta', () => { }) describe('field meta derived properties', () => { + it('orders validator errors by scope and pipeline index', () => { + const form = new InternalFormApi({ + defaultValues: { profile: { name: '' } }, + validators: [ + { run: () => null, triggers: [] }, + { run: () => null, triggers: [] }, + ], + }) + const group = new InternalFormGroupApi({ + form, + name: 'profile', + validators: [ + { run: () => null, triggers: [] }, + { run: () => null, triggers: [] }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'profile.name', + validators: [ + { run: () => null, triggers: [] }, + { run: () => null, triggers: [] }, + ], + }) + const [fieldFirst, fieldSecond] = field._validatorInstances! + const [groupFirst, groupSecond] = group._validatorInstances! + const [formFirst, formSecond] = form._validatorInstances! + + const validationSourceErrors: ValidationSourceErrorMap = new Map() + // Deliberately reverse both scope and pipeline insertion order. + validationSourceErrors.set(form._onSubmitSource, { + errors: [{ message: 'onSubmit' }], + sourceEvent: 'submit', + }) + validationSourceErrors.set(formSecond!, { + errors: [{ message: 'form 1' }], + sourceEvent: '', + }) + validationSourceErrors.set(formFirst!, { + errors: [{ message: 'form 0' }], + sourceEvent: '', + }) + validationSourceErrors.set(groupSecond!, { + errors: [{ message: 'group 1' }], + sourceEvent: '', + }) + validationSourceErrors.set(groupFirst!, { + errors: [{ message: 'group 0' }], + sourceEvent: '', + }) + validationSourceErrors.set(fieldSecond!, { + errors: [{ message: 'field 1' }], + sourceEvent: '', + }) + validationSourceErrors.set(fieldFirst!, { + errors: [{ message: 'field 0' }], + sourceEvent: '', + }) + + field._setMeta((prev) => ({ + ...prev, + _validationSourceErrors: validationSourceErrors, + })) + + expect(field.meta.original.errors.map((error) => error.message)).toEqual([ + 'field 0', + 'field 1', + 'group 0', + 'group 1', + 'form 0', + 'form 1', + 'onSubmit', + ]) + + group._cleanup() + }) + it('starts with defaultFieldMeta values', () => { const form = new InternalFormApi({ defaultValues: { x: '' } }) const field = form._getOrCreateFieldApi({ name: 'x' }) @@ -118,10 +220,7 @@ describe('field - meta', () => { const parent = form._getOrCreateFieldApi({ name: 'a' }) const child = form._getOrCreateFieldApi({ name: 'a.b' }) - child._setMeta((prev) => ({ - ...prev, - _fieldValidatorErrors: [[{ message: 'Required' }]], - })) + setFieldValidatorErrors(child, [{ message: 'Required' }]) vi.waitFor(() => { expect(child.meta.isSelfValid).toBe(false) @@ -142,17 +241,11 @@ describe('field - meta', () => { const parent = form._getOrCreateFieldApi({ name: 'a' }) const child = form._getOrCreateFieldApi({ name: 'a.b' }) - child._setMeta((prev) => ({ - ...prev, - _fieldValidatorErrors: [[{ message: 'Required' }]], - })) + setFieldValidatorErrors(child, [{ message: 'Required' }]) expect(parent._getBaseMeta().childContributionCounts.error).toBe(1) - child._setMeta((prev) => ({ - ...prev, - _fieldValidatorErrors: [[]], - })) + setFieldValidatorErrors(child, []) expect(parent._getBaseMeta().childContributionCounts.error).toBe(0) }) @@ -162,14 +255,8 @@ describe('field - meta', () => { const parent = form._getOrCreateFieldApi({ name: 'a' }) const child = form._getOrCreateFieldApi({ name: 'a.b' }) - parent._setMeta((prev) => ({ - ...prev, - _fieldValidatorErrors: [[{ message: 'Parent error' }]], - })) - child._setMeta((prev) => ({ - ...prev, - _fieldValidatorErrors: [[{ message: 'Child error' }]], - })) + setFieldValidatorErrors(parent, [{ message: 'Parent error' }]) + setFieldValidatorErrors(child, [{ message: 'Child error' }]) vi.waitFor(() => { expect(parent.meta.isSelfValid).toBe(false) diff --git a/packages/form-core/tests/FieldApi/validation.spec.ts b/packages/form-core/tests/FieldApi/validation.spec.ts index 6a75b589e9..246c06e646 100644 --- a/packages/form-core/tests/FieldApi/validation.spec.ts +++ b/packages/form-core/tests/FieldApi/validation.spec.ts @@ -318,25 +318,33 @@ describe('field - linked validators', () => { }) const unregister = targetField._register() const sourceField = form._getOrCreateFieldApi({ name: 'source' }) + const [otherValidatorInstance, sourceValidatorInstance] = + targetField._validatorInstances! sourceField.handleChange('source') await vi.runOnlyPendingTimersAsync() expect(otherValidator).not.toHaveBeenCalled() expect(sourceValidator).toHaveBeenCalledOnce() - expect(targetField.meta._fieldValidatorErrors[0]).toEqual([]) - expect(targetField.meta._fieldValidatorErrors[1]).toEqual([ - { message: 'source error' }, - ]) + expect( + targetField.meta._validationSourceErrors?.get(otherValidatorInstance!), + ).toBeUndefined() + expect( + targetField.meta._validationSourceErrors?.get(sourceValidatorInstance!) + ?.errors, + ).toEqual([{ message: 'source error' }]) expect(targetField.errors).toEqual([{ message: 'source error' }]) sourceField.handleChange('updated source') await vi.runOnlyPendingTimersAsync() - expect(targetField.meta._fieldValidatorErrors[0]).toEqual([]) - expect(targetField.meta._fieldValidatorErrors[1]).toEqual([ - { message: 'updated source error' }, - ]) + expect( + targetField.meta._validationSourceErrors?.get(otherValidatorInstance!), + ).toBeUndefined() + expect( + targetField.meta._validationSourceErrors?.get(sourceValidatorInstance!) + ?.errors, + ).toEqual([{ message: 'updated source error' }]) expect(targetField.errors).toEqual([{ message: 'updated source error' }]) unregister() @@ -379,7 +387,7 @@ describe('field - linked validators', () => { expect(firstValidator).toHaveBeenCalledOnce() expect(secondValidator).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( - 'Field validator: cyclical validator cycle detected. Check around the field first', + expect.stringContaining('cyclical validator cycle detected'), ) warn.mockRestore() @@ -396,14 +404,17 @@ describe('field - linked validators', () => { ], }) const sourceField = form._getOrCreateFieldApi({ name: 'source' }) + const validatorInstance = targetField._validatorInstances![0]! expect(sourceField._watchingValidatorFields?.has(targetField)).toBe(true) - expect(targetField._validateOnFields?.[0]?.[0]?.field).toBe(sourceField) + expect(validatorInstance.resolvedWatchFields?.get('source')).toBe( + sourceField, + ) form.deleteField('target') expect(sourceField._watchingValidatorFields).toBeNull() - expect(targetField._validateOnFields).toBeNull() + expect(validatorInstance.resolvedWatchFields).toBeNull() expect(targetField._isKilled).toBe(true) expect(form._tryGetFieldApi('source')).toBeNull() expect(form._tryGetFieldApi('target')).toBeNull() diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index 04bcfb715e..7d65926a2d 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' import { defaultInternalBaseFieldMeta } from '../../src/FieldApi/fieldState.lib' +import { InternalValidatorInstance } from '../../src/ValidatorInstance.lib' describe('form - lifecycle', () => { describe('initial state', () => { @@ -159,11 +161,78 @@ describe('form - lifecycle', () => { }) expect(warn).toHaveBeenCalledWith( - 'TanStack Form: The length of the validator array should not change after initialization', + expect.stringContaining( + 'length of the validator array should not change', + ), ) warn.mockRestore() }) + it('keeps form validator instances stable by slot across updates', () => { + const firstDefinition = { run: () => null, triggers: [] } + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [firstDefinition], + }) + const instance = form._validatorInstances?.[0] + const nextDefinition = { run: () => null, triggers: [] } + + form._update({ + defaultValues: { name: '' }, + validators: [nextDefinition], + }) + + expect(form._validatorInstances?.[0]).toBe(instance) + expect(instance?.definition).toBe(nextDefinition) + expect(instance?.owner).toBe(form) + expect(instance?.scope).toBe(validationSourceScopes.form) + expect(instance?.revision).toBe(1) + }) + + it('keeps the onSubmit source stable across callback updates', () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + }) + const onSubmitSource = form._onSubmitSource + + form._update({ + defaultValues: { name: '' }, + onSubmit: () => null, + }) + expect(form._onSubmitSource).toBe(onSubmitSource) + + form._update({ + defaultValues: { name: '' }, + onSubmit: () => ({ message: 'updated' }), + }) + + expect(form._onSubmitSource).toBe(onSubmitSource) + expect(onSubmitSource.owner).toBe(form) + expect(onSubmitSource.scope).toBe(validationSourceScopes.onSubmit) + expect(onSubmitSource.index).toBe(0) + }) + + it('resets form validator runtime without replacing its instance', () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => null, triggers: [] }], + }) + const instance = form._validatorInstances?.[0] + const abortController = new AbortController() + instance?.setAbortController(abortController) + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + + form.reset() + + expect(form._validatorInstances?.[0]).toBe(instance) + expect(abortController.signal.aborted).toBe(true) + expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.disposed).toBe(false) + }) + it('should only apply options to the leaf node', async () => { vi.useFakeTimers() const listener = vi.fn() @@ -341,6 +410,11 @@ describe('form - lifecycle', () => { it('should reset field', () => { const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) const field = form._getOrCreateFieldApi({ name: 'name' }) + const validatorInstance = new InternalValidatorInstance({ + definition: { run: () => null, triggers: [] }, + owner: field, + scope: 'field', + }) field.handleChange('bye') field.handleBlur() field._setMeta((prev) => ({ @@ -348,8 +422,12 @@ describe('form - lifecycle', () => { isValidating: true, _validationCount: 1, _arrayVersion: 1, - _fieldValidatorErrors: [[{ message: 'Reset me' }]], - _fieldValidatorErrorSourceEvents: ['change'], + _validationSourceErrors: new Map([ + [ + validatorInstance, + { errors: [{ message: 'Reset me' }], sourceEvent: 'change' }, + ], + ]), })) expect(field._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) @@ -420,10 +498,20 @@ describe('form - lifecycle', () => { expect(form.state.errors).toEqual([ expect.objectContaining({ message: 'Submission failed' }), ]) + expect( + form._atoms.meta.formErrors + .get() + .validationSourceErrors?.get(form._onSubmitSource)?.errors, + ).toEqual([expect.objectContaining({ message: 'Submission failed' })]) field.handleChange('Alice') expect(form.state.errors).toEqual([]) + expect( + form._atoms.meta.formErrors + .get() + .validationSourceErrors?.get(form._onSubmitSource), + ).toBeUndefined() }) it('clears form-level submit errors when any field blurs', async () => { @@ -578,11 +666,20 @@ describe('form - lifecycle', () => { await form.handleSubmit() expect(nameField.errors).toEqual([{ message: 'Name is required' }]) expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect( + nameField + ._getBaseMeta() + ._validationSourceErrors?.get(form._onSubmitSource)?.errors, + ).toEqual([{ message: 'Name is required' }]) + expect(form._onSubmitSource.errorTargets).toEqual( + new Set([nameField, emailField]), + ) nameField.handleChange('Alice') expect(nameField.errors).toEqual([]) expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form._onSubmitSource.errorTargets).toEqual(new Set([emailField])) }) it('only clears field-level submit errors for the field that blurs', async () => { diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index 79bbf91ee9..b191bb5de2 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -477,6 +477,35 @@ describe('form - submission handling', () => { ) }) + it('clears prior schema output when a dynamic submit predicate skips the validator', async () => { + let shouldRunOnSubmit = true + const onSubmit = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: 'test' }, + validators: [ + { + run: z.object({ name: z.string() }), + runOnSubmit: () => shouldRunOnSubmit, + triggers: [], + }, + ], + onSubmit, + }) + + await form.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ schemaOutputs: [{ name: 'test' }] }), + ) + + shouldRunOnSubmit = false + await form.handleSubmit() + + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ schemaOutputs: [undefined] }), + ) + expect(form._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) + }) + it('skips bailIfInvalid form validators when field validators fail first', async () => { const formValidatorFn = vi .fn() diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 2f506a5a9f..2ca600b9a8 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import type { StandardSchemaV1 } from '../../src/standardSchema.public' describe('form - validation', () => { describe('validate', () => { @@ -402,7 +403,6 @@ describe('form - validation', () => { triggers: ['blur'], }, { - // eslint-disable-next-line @typescript-eslint/require-await run: async () => ({ message: 'Async error' }), triggers: ['blur'], }, @@ -439,7 +439,6 @@ describe('form - validation', () => { triggers: ['blur'], }, { - // eslint-disable-next-line @typescript-eslint/require-await run: async () => ({ message: 'Async error' }), triggers: ['blur'], }, @@ -800,6 +799,65 @@ describe('form - validation', () => { } }) + it('logs rejected asynchronous mount validator errors without storing them', async () => { + const error = new Error('Async mount validator failed') + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + runOnMount: true, + triggers: [], + run: () => Promise.reject(error), + }, + ], + }) + + await vi.waitFor(() => expect(form.state.isValidating).toBe(false)) + + expect(consoleSpy).toHaveBeenCalledWith(error) + expect(form.state.errors).toEqual([]) + expect(form._validatorInstances![0]!.abortController).toBeNull() + } finally { + consoleSpy.mockRestore() + } + }) + + it('logs rejected mount schema errors without storing them', async () => { + const error = new Error('Mount schema failed') + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const schema = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => Promise.reject(error), + }, + } satisfies StandardSchemaV1 + + try { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: schema, + runOnMount: true, + triggers: [], + }, + ], + }) + + await vi.waitFor(() => expect(form.state.isValidating).toBe(false)) + + expect(consoleSpy).toHaveBeenCalledWith(error) + expect(form.state.errors).toEqual([]) + expect(form._validatorInstances![0]!.abortController).toBeNull() + } finally { + consoleSpy.mockRestore() + } + }) + it('skips validators that are not opted into mount before later mount validators', () => { const skippedValidator = vi.fn(() => 'Should not run') const mountValidator = vi.fn(() => 'Mount error') @@ -1655,6 +1713,19 @@ describe('form - validation', () => { void field.atom field.handleChange('New value') await vi.runAllTimersAsync() + const formValidatorInstance = form._validatorInstances![0]! + expect(form._tryGetFieldApi('name')).toBe(field) + expect(Array.from(formValidatorInstance.errorTargets ?? [])).toEqual([ + field, + ]) + expect( + field + ._getBaseMeta() + ._validationSourceErrors?.get(formValidatorInstance), + ).toEqual({ + errors: [{ message: 'Form-level error' }], + sourceEvent: 'change', + }) expect(field.errors).toEqual([{ message: 'Form-level error' }]) }) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 4a4dc4466f..25c563bedb 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' import { InternalFormGroupApi } from '../../src/FormGroupApi/FormGroupApi.lib' +import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' describe('FormGroupApi', () => { it('runs synchronous mount validators and stores group errors', () => { @@ -314,6 +315,82 @@ describe('FormGroupApi', () => { expect(group._options.onSubmit).toBe(onSubmit) }) + it('keeps group validator instances stable by slot across updates', () => { + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: 'Tony' } }, + }) + const firstDefinition = { run: () => null, triggers: [] } + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [firstDefinition], + }) + const instance = group._validatorInstances?.[0] + const nextDefinition = { run: () => null, triggers: [] } + + group.update({ + form, + name: 'guestDetails', + validators: [nextDefinition], + }) + + expect(group._validatorInstances?.[0]).toBe(instance) + expect(instance?.definition).toBe(nextDefinition) + expect(instance?.owner).toBe(group) + expect(instance?.scope).toBe(validationSourceScopes.group) + expect(instance?.revision).toBe(1) + }) + + it('warns when the group validator array length changes after initialization', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: 'Tony' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + }) + + group.update({ + form, + name: 'guestDetails', + validators: [{ run: () => null, triggers: [] }], + }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + warn.mockRestore() + }) + + it('resets validator runtime during cleanup and preserves remount identity', () => { + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: 'Tony' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ run: () => null, triggers: [] }], + }) + const instance = group._validatorInstances?.[0] + const abortController = new AbortController() + instance?.setAbortController(abortController) + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + + group._cleanup() + group.mount() + + expect(group._validatorInstances?.[0]).toBe(instance) + expect(abortController.signal.aborted).toBe(true) + expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.disposed).toBe(false) + }) + it('stores the group on its trie node and follows that node when it moves', () => { const form = new InternalFormApi({ defaultValues: { @@ -415,14 +492,15 @@ describe('FormGroupApi', () => { }) await group.validate('submit') - expect(group._routedErrorFields[0]).toEqual( + const validatorInstance = group._validatorInstances![0]! + expect(validatorInstance.errorTargets).toEqual( new Set([nameField, emailField]), ) form.deleteField('guestDetails.name') expect(nameField._isKilled).toBe(true) - expect(group._routedErrorFields[0]).toEqual(new Set([emailField])) + expect(validatorInstance.errorTargets).toEqual(new Set([emailField])) }) it('clears backing-node validation when cleanup cancels a group run', async () => { @@ -769,6 +847,39 @@ describe('FormGroupApi', () => { ) }) + it('clears prior group schema output when a dynamic submit predicate skips the validator', async () => { + let shouldRunOnSubmit = true + const onSubmit = vi.fn() + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: 'Tony' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [ + { + run: z.object({ name: z.string() }), + runOnSubmit: () => shouldRunOnSubmit, + triggers: [], + }, + ], + onSubmit, + }) + + await group.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ schemaOutputs: [{ name: 'Tony' }] }), + ) + + shouldRunOnSubmit = false + await group.handleSubmit() + + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ schemaOutputs: [undefined] }), + ) + expect(group._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) + }) + it('keeps submission lifecycle independent between sibling groups', async () => { let resolveSubmit!: () => void const submitting = new Promise((resolve) => { @@ -1018,10 +1129,14 @@ describe('FormGroupApi', () => { await group.validate('submit') expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(nameField._getBaseMeta()._formGroupValidatorErrors).toEqual({ - errors: [[{ message: 'Name is required' }]], - errorSourceEvents: ['submit'], + const validatorInstance = group._validatorInstances![0]! + expect( + nameField._getBaseMeta()._validationSourceErrors?.get(validatorInstance), + ).toEqual({ + errors: [{ message: 'Name is required' }], + sourceEvent: 'submit', }) + expect(nameField._getBaseMeta()._validationSourceErrors?.size).toBe(1) expect(nameField.meta.isInvalid).toBe(true) }) @@ -1077,13 +1192,22 @@ describe('FormGroupApi', () => { { message: 'Group name error' }, { message: 'Root name error' }, ]) - expect(nameField._getBaseMeta()._formGroupValidatorErrors).toEqual({ - errors: [[{ message: 'Group name error' }]], - errorSourceEvents: ['submit'], + expect( + nameField + ._getBaseMeta() + ._validationSourceErrors?.get(group._validatorInstances![0]!), + ).toEqual({ + errors: [{ message: 'Group name error' }], + sourceEvent: 'submit', + }) + expect( + nameField + ._getBaseMeta() + ._validationSourceErrors?.get(form._validatorInstances![0]!), + ).toEqual({ + errors: [{ message: 'Root name error' }], + sourceEvent: 'submit', }) - expect(nameField._getBaseMeta()._formValidatorErrors).toEqual([ - [{ message: 'Root name error' }], - ]) }) it('keeps sibling group validator errors independently owned', async () => { diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts new file mode 100644 index 0000000000..9bf5dc10a8 --- /dev/null +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -0,0 +1,425 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { LiteDebouncer } from '@tanstack/pacer-lite' +import { + InternalValidatorInstance, + reconcileValidatorInstances, +} from '../src/ValidatorInstance.lib' +import { validationSourceScopes } from '../src/ValidationSourceInstance.lib' + +type TestDebouncedFn = (value: string) => void + +const createDefinition = (message: string) => ({ + run: () => ({ message }), + triggers: ['change'] as const, +}) + +function createInstance() { + const definition = createDefinition('initial') + const owner = { name: 'name' as const } + + return new InternalValidatorInstance< + typeof definition, + typeof owner, + string, + { name: string }, + string | undefined, + TestDebouncedFn + >({ + definition, + owner, + scope: 'field', + }) +} + +describe('InternalValidatorInstance', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('uses the shared scope priorities for validation source ordering', () => { + expect(validationSourceScopes).toEqual({ + field: 0, + group: 1, + form: 2, + onSubmit: 3, + }) + }) + + it('stores its installation and starts with empty runtime state', () => { + const first = createInstance() + const second = createInstance() + + expect(first).not.toBe(second) + expect(first.definition.run()).toEqual({ message: 'initial' }) + expect(first.owner).toEqual({ name: 'name' }) + expect(first.scope).toBe(validationSourceScopes.field) + expect(first.index).toBe(0) + expect(first.abortController).toBeNull() + expect(first.debouncer).toBeNull() + expect(first.schemaOutput).toBeUndefined() + expect(first.hasSchemaOutput).toBe(false) + expect(first.errorTargets).toBeNull() + expect(first.resolvedWatchFields).toBeNull() + expect(first.didRunOnMount).toBe(false) + expect(first.revision).toBe(0) + expect(first.disposed).toBe(false) + + first.addErrorTarget('temporary') + first.deleteErrorTarget('temporary') + first.setResolvedWatchField('temporary', { name: 'temporary' }) + first.deleteResolvedWatchField('temporary') + + expect(first.errorTargets).toBeNull() + expect(first.resolvedWatchFields).toBeNull() + + expectTypeOf(first.definition).toEqualTypeOf< + ReturnType + >() + expectTypeOf(first.owner).toEqualTypeOf<{ name: 'name' }>() + expectTypeOf(first.errorTargets).toEqualTypeOf | null>() + expectTypeOf(first.resolvedWatchFields).toEqualTypeOf | null>() + expectTypeOf(first.schemaOutput).toEqualTypeOf() + expectTypeOf( + first.debouncer, + ).toEqualTypeOf | null>() + }) + + it('updates its definition without disturbing other state', () => { + const instance = createInstance() + const initialDefinition = instance.definition + const abortController = new AbortController() + const debouncedFn = vi.fn((_value: string) => {}) + const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) + const watchedField = { name: 'source' } + + instance.setAbortController(abortController) + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + instance.addErrorTarget('target') + instance.setResolvedWatchField('source', watchedField) + instance.markMountValidationRan() + + instance.updateDefinition(initialDefinition) + expect(instance.revision).toBe(1) + + const nextDefinition = createDefinition('updated') + instance.updateDefinition(nextDefinition) + + expect(instance.definition).toBe(nextDefinition) + expect(instance.revision).toBe(2) + expect(instance.abortController).toBe(abortController) + expect(instance.debouncer).toBe(debouncer) + expect(instance.schemaOutput).toBe('output') + expect(instance.hasSchemaOutput).toBe(true) + expect(instance.errorTargets).toEqual(new Set(['target'])) + expect(instance.resolvedWatchFields?.get('source')).toBe(watchedField) + expect(instance.didRunOnMount).toBe(true) + expect(abortController.signal.aborted).toBe(false) + }) + + it('owns abort-controller replacement and clearing', () => { + const instance = createInstance() + const firstController = new AbortController() + const secondController = new AbortController() + + instance.setAbortController(firstController) + instance.setAbortController(secondController) + expect(firstController.signal.aborted).toBe(true) + expect(instance.abortController).toBe(secondController) + + instance.clearAbortController(firstController) + expect(instance.abortController).toBe(secondController) + instance.clearAbortController(secondController) + expect(instance.abortController).toBeNull() + expect(secondController.signal.aborted).toBe(false) + }) + + it('creates, reconfigures, and cancels a pacer-lite debouncer', async () => { + vi.useFakeTimers() + const instance = createInstance() + const firstFn = vi.fn((_value: string) => {}) + const secondFn = vi.fn((_value: string) => {}) + + const debouncer = instance.getOrCreateDebouncer(firstFn, 100) + expect(debouncer).toBeInstanceOf(LiteDebouncer) + expect(instance.debouncer).toBe(debouncer) + debouncer?.maybeExecute('first') + + const reconfigured = instance.getOrCreateDebouncer(secondFn, 200) + expect(reconfigured).toBe(debouncer) + expect(reconfigured?.fn).toBe(secondFn) + expect(reconfigured?.options.wait).toBe(200) + reconfigured?.maybeExecute('second') + + await vi.advanceTimersByTimeAsync(199) + expect(firstFn).not.toHaveBeenCalled() + expect(secondFn).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(firstFn).not.toHaveBeenCalled() + expect(secondFn).toHaveBeenCalledOnce() + expect(secondFn).toHaveBeenCalledWith('second') + + reconfigured?.maybeExecute('cancelled') + instance.cancelExecution() + await vi.advanceTimersByTimeAsync(200) + + expect(secondFn).toHaveBeenCalledOnce() + expect(instance.debouncer).toBeNull() + }) + + it('distinguishes an unset schema output from an undefined output', () => { + const instance = createInstance() + + instance.setSchemaOutput({ + schemaResult: 'ignored', + hasSchemaResult: false, + }) + expect(instance.schemaOutput).toBeUndefined() + expect(instance.hasSchemaOutput).toBe(false) + + instance.setSchemaOutput({ + schemaResult: undefined, + hasSchemaResult: true, + }) + expect(instance.schemaOutput).toBeUndefined() + expect(instance.hasSchemaOutput).toBe(true) + + instance.clearSchemaOutput() + expect(instance.schemaOutput).toBeUndefined() + expect(instance.hasSchemaOutput).toBe(false) + }) + + it('resets runtime state while preserving its installation', async () => { + vi.useFakeTimers() + const instance = createInstance() + const definition = createDefinition('updated') + const owner = instance.owner + const abortController = new AbortController() + const debouncedFn = vi.fn((_value: string) => {}) + const watchedField = { name: 'source' } + + instance.updateDefinition(definition) + instance.setAbortController(abortController) + const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + instance.addErrorTarget('target') + instance.setResolvedWatchField('source', watchedField) + instance.markMountValidationRan() + debouncer?.maybeExecute('cancelled') + + instance.resetRuntime() + await vi.advanceTimersByTimeAsync(100) + + expect(abortController.signal.aborted).toBe(true) + expect(debouncedFn).not.toHaveBeenCalled() + expect(instance.abortController).toBeNull() + expect(instance.debouncer).toBeNull() + expect(instance.schemaOutput).toBeUndefined() + expect(instance.hasSchemaOutput).toBe(false) + expect(instance.errorTargets).toBeNull() + expect(instance.resolvedWatchFields?.get('source')).toBe(watchedField) + expect(instance.didRunOnMount).toBe(true) + expect(instance.definition).toBe(definition) + expect(instance.owner).toBe(owner) + expect(instance.scope).toBe(validationSourceScopes.field) + expect(instance.revision).toBe(1) + expect(instance.disposed).toBe(false) + }) + + it('disposes once and ignores later mutations', async () => { + vi.useFakeTimers() + const instance = createInstance() + const definition = instance.definition + const abortController = new AbortController() + const debouncedFn = vi.fn((_value: string) => {}) + + instance.setAbortController(abortController) + const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) + instance.addErrorTarget('target') + instance.setResolvedWatchField('source', { name: 'source' }) + instance.markMountValidationRan() + debouncer?.maybeExecute('cancelled') + + const onBeforeDispose = vi.fn((disposingInstance: typeof instance) => { + expect(disposingInstance).toBe(instance) + expect(disposingInstance.disposed).toBe(false) + expect(disposingInstance.errorTargets).toEqual(new Set(['target'])) + expect(disposingInstance.resolvedWatchFields?.has('source')).toBe(true) + }) + + instance.dispose(onBeforeDispose) + instance.dispose(onBeforeDispose) + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeDispose).toHaveBeenCalledOnce() + expect(abortController.signal.aborted).toBe(true) + expect(debouncedFn).not.toHaveBeenCalled() + expect(instance.abortController).toBeNull() + expect(instance.debouncer).toBeNull() + expect(instance.schemaOutput).toBeUndefined() + expect(instance.hasSchemaOutput).toBe(false) + expect(instance.errorTargets).toBeNull() + expect(instance.resolvedWatchFields).toBeNull() + expect(instance.didRunOnMount).toBe(false) + expect(instance.disposed).toBe(true) + + const nextController = new AbortController() + const nextDebouncedFn = vi.fn((_value: string) => {}) + instance.updateDefinition(createDefinition('ignored')) + instance.setAbortController(nextController) + instance.clearAbortController(nextController) + const nextDebouncer = instance.getOrCreateDebouncer(nextDebouncedFn, 100) + instance.setSchemaOutput({ + schemaResult: 'ignored', + hasSchemaResult: true, + }) + instance.clearSchemaOutput() + instance.addErrorTarget('ignored') + instance.deleteErrorTarget('target') + instance.setResolvedWatchField('ignored', { name: 'ignored' }) + instance.deleteResolvedWatchField('source') + instance.markMountValidationRan() + instance.cancelExecution() + instance.resetRuntime() + + expect(instance.definition).toBe(definition) + expect(instance.revision).toBe(0) + expect(instance.abortController).toBeNull() + expect(instance.debouncer).toBeNull() + expect(instance.hasSchemaOutput).toBe(false) + expect(instance.errorTargets).toBeNull() + expect(instance.resolvedWatchFields).toBeNull() + expect(instance.didRunOnMount).toBe(false) + expect(nextController.signal.aborted).toBe(false) + expect(nextDebouncer).toBeNull() + }) +}) + +describe('reconcileValidatorInstances', () => { + it('normalizes missing and empty definitions to null', () => { + const owner = { name: 'form' } + + expect( + reconcileValidatorInstances({ + definitions: undefined, + instances: null, + owner, + scope: 'form', + }), + ).toBeNull() + expect( + reconcileValidatorInstances({ + definitions: [], + instances: null, + owner, + scope: 'form', + }), + ).toBeNull() + }) + + it('preserves retained slots and disposes removed slots', () => { + const owner = { name: 'field' } + const firstDefinition = createDefinition('first') + const secondDefinition = createDefinition('second') + const initial = reconcileValidatorInstances({ + definitions: [firstDefinition, secondDefinition], + instances: null, + owner, + scope: 'field', + }) + const firstInstance = initial?.[0] + const secondInstance = initial?.[1] + const nextDefinition = createDefinition('next') + + const next = reconcileValidatorInstances({ + definitions: [nextDefinition], + instances: initial, + owner, + scope: 'field', + }) + + expect(next).toBe(initial) + expect(next).toEqual([firstInstance]) + expect(firstInstance?.definition).toBe(nextDefinition) + expect(firstInstance?.revision).toBe(1) + expect(secondInstance?.disposed).toBe(true) + }) + + it('runs owner cleanup before disposing removed instances', () => { + const owner = { name: 'field' } + const initial = reconcileValidatorInstances({ + definitions: [createDefinition('first'), createDefinition('second')], + instances: null, + owner, + scope: 'field', + }) + const removedInstance = initial![1]! + const onBeforeDispose = vi.fn((instance) => { + expect(instance.disposed).toBe(false) + }) + + reconcileValidatorInstances({ + definitions: [createDefinition('next')], + instances: initial, + owner, + scope: 'field', + onBeforeDispose, + }) + + expect(onBeforeDispose).toHaveBeenCalledWith(removedInstance) + expect(removedInstance.disposed).toBe(true) + }) + + it('creates added slots and disposes all slots when cleared', () => { + const owner = { name: 'group' } + const firstDefinition = createDefinition('first') + const initial = reconcileValidatorInstances({ + definitions: [firstDefinition], + instances: null, + owner, + scope: 'group', + }) + const firstInstance = initial?.[0] + const secondDefinition = createDefinition('second') + + const expanded = reconcileValidatorInstances({ + definitions: [firstDefinition, secondDefinition], + instances: initial, + owner, + scope: 'group', + }) + const secondInstance = expanded?.[1] + + expect(expanded).toBe(initial) + expect(firstInstance?.revision).toBe(1) + expect(secondInstance?.definition).toBe(secondDefinition) + expect(secondInstance?.owner).toBe(owner) + expect(secondInstance?.scope).toBe(validationSourceScopes.group) + expect(firstInstance?.index).toBe(0) + expect(secondInstance?.index).toBe(1) + + expect( + reconcileValidatorInstances({ + definitions: null, + instances: expanded, + owner, + scope: 'group', + }), + ).toBeNull() + expect(firstInstance?.disposed).toBe(true) + expect(secondInstance?.disposed).toBe(true) + }) +}) diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index e8bc722b37..b2c6fa92bb 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' import { validateServerValues } from '../src/internals' import { formOptions, initialServerFormState } from '../src' @@ -67,6 +68,63 @@ describe('server validation', () => { expect(serverValidator).toHaveBeenCalledOnce() }) + it('disposes every validator once when the server pipeline rejects', async () => { + const error = new Error('Pipeline failed') + const disposeSpy = vi.spyOn(InternalValidatorInstance.prototype, 'dispose') + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: () => null, + get triggers(): ['server'] { + throw error + }, + }, + { + run: () => null, + triggers: ['server'], + }, + ], + }) + + try { + await expect(validateServerValues(options, { name: '' })).rejects.toBe( + error, + ) + expect(disposeSpy).toHaveBeenCalledTimes(2) + expect(new Set(disposeSpy.mock.instances).size).toBe(2) + } finally { + disposeSpy.mockRestore() + } + }) + + it('rethrows validator errors after disposing their instance once', async () => { + const error = new Error('Validator failed') + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const disposeSpy = vi.spyOn(InternalValidatorInstance.prototype, 'dispose') + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: () => { + throw error + }, + triggers: ['server'], + }, + ], + }) + + try { + await expect(validateServerValues(options, { name: '' })).rejects.toBe( + error, + ) + expect(disposeSpy).toHaveBeenCalledOnce() + } finally { + disposeSpy.mockRestore() + consoleSpy.mockRestore() + } + }) + it('runs server-only validators during client submit by default', async () => { const serverValidator = vi.fn(() => 'Server error') const form = new InternalFormApi( @@ -140,6 +198,10 @@ describe('server validation', () => { }) it('returns values and schema outputs when server validation succeeds', async () => { + const setSchemaOutput = vi.spyOn( + InternalValidatorInstance.prototype, + 'setSchemaOutput', + ) const options = formOptions({ defaultValues: { name: '' }, validators: [ @@ -154,12 +216,49 @@ describe('server validation', () => { ], }) + try { + const result = expectServerValidateSuccess( + await validateServerValues(options, { name: 'Tony' }), + ) + + expect(result.values).toEqual({ name: 'Tony' }) + expect(result.schemaOutputs).toEqual([{ nameLength: 4 }]) + expect(setSchemaOutput).not.toHaveBeenCalled() + } finally { + setSchemaOutput.mockRestore() + } + }) + + it('aligns server schema outputs with all validator slots', async () => { + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: z.object({ name: z.string() }), + triggers: ['change'], + }, + { + run: () => null, + triggers: ['server'], + }, + { + run: z + .object({ name: z.string() }) + .transform(({ name }) => ({ nameLength: name.length })), + triggers: ['server'], + }, + ], + }) + const result = expectServerValidateSuccess( await validateServerValues(options, { name: 'Tony' }), ) - expect(result.values).toEqual({ name: 'Tony' }) - expect(result.schemaOutputs).toEqual([{ nameLength: 4 }]) + expect(result.schemaOutputs).toEqual([ + undefined, + undefined, + { nameLength: 4 }, + ]) }) it('returns a serializable server state when validation fails', async () => { @@ -455,6 +554,35 @@ describe('server validation', () => { expect(form.state.errors).toEqual([{ message: 'Server name error' }]) }) + it('does not restore serialized schema output during hydration', () => { + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: z.object({ name: z.string() }).transform(() => undefined), + triggers: ['server'], + }, + ], + serverState: { + values: { name: 'Tony' }, + validationResults: [ + { + validatorIndex: 0, + result: null, + schemaResult: undefined, + hasSchemaResult: true, + }, + ], + submissionAttempts: 1, + }, + }) + + const form = new InternalFormApi(options) + + expect(form._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) + expect(form._validatorInstances?.[0]?.schemaOutput).toBeUndefined() + }) + it('notifies Devtools when server state directly resets field meta', () => { const options = formOptions({ defaultValues: { name: '' } }) const form = new InternalFormApi(options) diff --git a/packages/form-core/tests/validation-errors.test.ts b/packages/form-core/tests/validation-errors.test.ts index 701f3afb78..522472593e 100644 --- a/packages/form-core/tests/validation-errors.test.ts +++ b/packages/form-core/tests/validation-errors.test.ts @@ -5,8 +5,16 @@ import { parseValidationResult, reconcileRoutedFieldErrors, } from '../src/validation' +import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' +function createTestValidatorInstance() { + return new InternalValidatorInstance({ + definition: { run: () => null, triggers: [] }, + owner: {}, + scope: 'form', + }) +} describe('parseValidationResult', () => { it('returns no stored errors for valid results', () => { const validResults: Array> = [ @@ -108,25 +116,27 @@ describe('parseValidationResult', () => { describe('reconcileRoutedFieldErrors', () => { it('sets errors on already-resolved field refs', () => { + const validatorInstance = createTestValidatorInstance() const field = { name: 'name' } as AnyInternalFieldApi const errors = [{ message: 'Name is required' }] const setFieldError = vi.fn() const result = reconcileRoutedFieldErrors( - 2, + validatorInstance, [[field, errors]], undefined, setFieldError, vi.fn(), ) - expect(setFieldError).toHaveBeenCalledWith(field, 2, errors) + expect(setFieldError).toHaveBeenCalledWith(field, validatorInstance, errors) expect(result.fieldRefs).toEqual(new Set([field])) expect(result.affectedFields).toEqual(new Set([field])) }) it('reports unchanged refs when no new or old field refs exist', () => { + const validatorInstance = createTestValidatorInstance() const result = reconcileRoutedFieldErrors( - 0, + validatorInstance, [], undefined, vi.fn(), @@ -139,8 +149,9 @@ describe('reconcileRoutedFieldErrors', () => { }) it('reports unchanged refs when the old field ref set is empty', () => { + const validatorInstance = createTestValidatorInstance() const result = reconcileRoutedFieldErrors( - 0, + validatorInstance, [], new Set(), vi.fn(), @@ -151,10 +162,11 @@ describe('reconcileRoutedFieldErrors', () => { }) it('clears stale old field refs when no new refs replace them', () => { + const validatorInstance = createTestValidatorInstance() const field = { name: 'name' } as AnyInternalFieldApi const clearFieldError = vi.fn() const result = reconcileRoutedFieldErrors( - 0, + validatorInstance, [], new Set([field]), vi.fn(), @@ -163,6 +175,6 @@ describe('reconcileRoutedFieldErrors', () => { expect(result.didFieldRefsChange).toBe(true) expect(result.affectedFields).toEqual(new Set([field])) - expect(clearFieldError).toHaveBeenCalledWith(field, 0) + expect(clearFieldError).toHaveBeenCalledWith(field, validatorInstance) }) }) diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index caf4fb2a00..c9443d8a65 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -5,6 +5,7 @@ import { runFormValidatorPipeline, } from '../src/validation' import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { reconcileValidatorInstances } from '../src/ValidatorInstance.lib' import type { PipelineResult } from '../src/validation' import type { ClientValidationTrigger, @@ -20,6 +21,7 @@ import type { ValidationPredicateFn, } from '../src' import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' +import type { StandardSchemaV1 } from '../src/standardSchema.public' describe('runFormValidatorPipeline', () => { type Event = Exclude['event'], 'server'> @@ -32,8 +34,14 @@ describe('runFormValidatorPipeline', () => { form: InternalFormApi, pipeline: Array>, ) { + const validatorInstances = reconcileValidatorInstances({ + definitions: pipeline, + instances: null, + owner: form, + scope: 'form', + })! return { - pipeline, + pipeline: validatorInstances, runWithContext: (args: { event: Event field?: AnyInternalFieldApi @@ -49,7 +57,7 @@ describe('runFormValidatorPipeline', () => { }, hasFailedBefore: args.hasFailedBefore ?? false, onResult: args.onResult, - pipeline: pipeline, + pipeline: validatorInstances, }).then((res) => res.results) }, } @@ -64,8 +72,14 @@ describe('runFormValidatorPipeline', () => { field: AnyInternalFieldApi, pipeline: Array>, ) { + const validatorInstances = reconcileValidatorInstances({ + definitions: pipeline, + instances: null, + owner: field, + scope: 'field', + })! return { - pipeline, + pipeline: validatorInstances, runWithContext: (args: { event: ClientValidationTrigger onResult?: (result: PipelineResult) => void @@ -78,7 +92,7 @@ describe('runFormValidatorPipeline', () => { fieldApi: field, }, onResult: args.onResult, - pipeline, + pipeline: validatorInstances, }).then((res) => res.results) }, } @@ -102,6 +116,66 @@ describe('runFormValidatorPipeline', () => { ) }) + it('should preserve form-owned data for form validation contexts', async () => { + const formApi = getForm({ users: [{ name: 'test' }] }) + const field = formApi._getOrCreateFieldApi({ name: 'users[0].name' }) + const issues = [ + { message: 'Invalid name', path: ['users', 0, 'name'] as const }, + ] + const run = vi.fn(({ triggerFieldApi, value, parseIssues }) => { + expect(triggerFieldApi).toBe(field) + expect(value).toBe(formApi.state.values) + expect(parseIssues(issues)).toEqual({ + form: issues, + fields: { 'users[0].name': issues }, + }) + return null + }) + const { runWithContext } = getPipeline(formApi, [ + { run, triggers: ['change'] }, + ]) + + await runWithContext({ event: 'change', field }) + + expect(run).toHaveBeenCalledOnce() + }) + + it('should preserve form-owned data for field validation contexts', async () => { + const formApi = getForm({ users: [{ name: 'test' }] }) + const field = formApi._getOrCreateFieldApi({ name: 'users[0].name' }) + const issues = [ + { message: 'Invalid name', path: ['users', 0, 'name'] as const }, + ] + const run = vi.fn((context) => { + const fieldContext = context as typeof context & { + fieldApi: AnyInternalFieldApi + } + + expect(fieldContext.fieldApi).toBe(field) + expect(fieldContext).not.toHaveProperty('triggerFieldApi') + expect(fieldContext.value).toBe(formApi.state.values) + expect(fieldContext.parseIssues(issues)).toEqual({ + form: issues, + fields: { 'users[0].name': issues }, + }) + return null + }) + const { pipeline } = getPipeline(formApi, [{ run, triggers: ['change'] }]) + + await runFormValidatorPipeline({ + context: { + scope: 'field', + event: 'change', + formApi, + fieldApi: field, + } as never, + hasFailedBefore: false, + pipeline, + }) + + expect(run).toHaveBeenCalledOnce() + }) + it('should provide error map helpers to form validators', async () => { const formApi = getForm({ name: '' }) const { runWithContext } = getPipeline(formApi, [ @@ -303,11 +377,32 @@ describe('runFormValidatorPipeline', () => { expect(run).toHaveBeenCalledOnce() }) + it('should settle as aborted when a disposed validator cannot create a debouncer', async () => { + const formApi = getForm({ name: '' }) + const run = vi.fn(() => ({ message: 'foo' })) + const onResult = vi.fn() + const { pipeline, runWithContext } = getPipeline(formApi, [ + { + run, + triggers: ['change'], + triggerDebounceMs: 100, + }, + ]) + + pipeline[0]!.dispose() + + await expect( + runWithContext({ event: 'change', onResult }), + ).resolves.toEqual([]) + expect(run).not.toHaveBeenCalled() + expect(onResult).not.toHaveBeenCalled() + }) + it('should debounce validation with a function', async () => { vi.useFakeTimers() - const formApi = getForm({ name: 'test' }) - const field = formApi._getOrCreateFieldApi({ name: 'name' }) + const form = getForm({ name: 'test' }) + const field = form._getOrCreateFieldApi({ name: 'name' }) const run = vi.fn(() => ({ message: 'foo' })) const triggerDebounceMs = vi.fn(({ scope, formApi, fieldApi, value }) => { expect(scope).toBe('form') @@ -318,7 +413,7 @@ describe('runFormValidatorPipeline', () => { return 100 }) - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext } = getPipeline(form, [ { run, triggers: ['change'], @@ -610,7 +705,7 @@ describe('runFormValidatorPipeline', () => { const run = vi.fn(() => validationResult) const onResult = vi.fn() - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext, pipeline } = getPipeline(formApi, [ { run, triggers: ['change'], @@ -633,7 +728,7 @@ describe('runFormValidatorPipeline', () => { expect(onResult).toHaveBeenCalledOnce() expect(onResult).toHaveBeenCalledWith( expect.objectContaining({ - validatorIndex: 0, + validatorInstance: pipeline[0], result: validationResult, }), ) @@ -819,7 +914,7 @@ describe('runFormValidatorPipeline', () => { age: z.number().min(0), }) - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext, pipeline } = getPipeline(formApi, [ { run: schema, triggers: [], @@ -834,6 +929,165 @@ describe('runFormValidatorPipeline', () => { name: 'test', age: 25, }) + expect(pipeline[0]?.hasSchemaOutput).toBe(true) + expect(pipeline[0]?.schemaOutput).toEqual({ + name: 'test', + age: 25, + }) + }) + + it.each(['change', 'blur'] as const)( + 'does not store schema output during %s validation', + async (event) => { + const formApi = getForm({ name: 'test' }) + const schema = z.object({ name: z.string() }) + const { runWithContext, pipeline } = getPipeline(formApi, [ + { + run: schema, + triggers: [event], + }, + ]) + + const results = await runWithContext({ event }) + + expect(results[0]?.schemaResult).toEqual({ name: 'test' }) + expect(pipeline[0]?.hasSchemaOutput).toBe(false) + }, + ) + + it('does not store field schema output during submit validation', async () => { + const formApi = getForm({ name: 'test' }) + const field = formApi._getOrCreateFieldApi({ name: 'name' }) + const schema = z.string().transform((name) => name.toUpperCase()) + const { runWithContext, pipeline } = getFieldPipeline(formApi, field, [ + { + run: schema, + triggers: [], + }, + ]) + + const results = await runWithContext({ event: 'submit' }) + + expect(results[0]?.schemaResult).toBe('TEST') + expect(pipeline[0]?.hasSchemaOutput).toBe(false) + }) + + it('commits schema output before notifying onResult', async () => { + const formApi = getForm({ name: 'test' }) + const schema = z.object({ name: z.string() }) + const { runWithContext, pipeline } = getPipeline(formApi, [ + { + run: schema, + triggers: [], + }, + ]) + const onResult = vi.fn(() => { + expect(pipeline[0]?.hasSchemaOutput).toBe(true) + expect(pipeline[0]?.schemaOutput).toEqual({ name: 'test' }) + }) + + await runWithContext({ event: 'submit', onResult }) + + expect(onResult).toHaveBeenCalledOnce() + }) + + it('does not commit stale async schema output', async () => { + const formApi = getForm({ name: 'stale' }) + let resolveFirst!: (result: { value: { name: string } }) => void + const firstResult = new Promise<{ value: { name: string } }>( + (resolve) => { + resolveFirst = resolve + }, + ) + const schema = { + '~standard': { + version: 1, + vendor: 'test', + validate: vi + .fn() + .mockReturnValueOnce(firstResult) + .mockReturnValueOnce({ value: { name: 'current' } }), + }, + } satisfies StandardSchemaV1<{ name: string }, { name: string }> + const { runWithContext, pipeline } = getPipeline(formApi, [ + { + run: schema, + triggers: [], + }, + ]) + + const staleValidation = runWithContext({ event: 'submit' }) + formApi.setFieldValue('name', 'current') + const currentValidation = runWithContext({ event: 'submit' }) + + await Promise.all([staleValidation, currentValidation]) + expect(pipeline[0]?.schemaOutput).toEqual({ name: 'current' }) + + resolveFirst({ value: { name: 'stale' } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(pipeline[0]?.schemaOutput).toEqual({ name: 'current' }) + }) + + it('clears schema output before a validator is skipped by bailIfInvalid', async () => { + const formApi = getForm({ name: 'test' }) + let shouldFail = false + const { runWithContext, pipeline } = getPipeline(formApi, [ + { + run: () => (shouldFail ? 'Invalid' : null), + triggers: [], + }, + { + bailIfInvalid: true, + run: z.object({ name: z.string() }), + triggers: [], + }, + ]) + + await runWithContext({ event: 'submit' }) + expect(pipeline[1]?.hasSchemaOutput).toBe(true) + + shouldFail = true + await runWithContext({ event: 'submit' }) + + expect(pipeline[1]?.hasSchemaOutput).toBe(false) + expect(pipeline[1]?.schemaOutput).toBeUndefined() + }) + + it('aborts pending schema output before a dynamic predicate skips the validator', async () => { + const formApi = getForm({ name: 'stale' }) + let shouldRunOnSubmit = true + let resolveSchema!: (result: { value: { name: string } }) => void + const schemaResult = new Promise<{ value: { name: string } }>( + (resolve) => { + resolveSchema = resolve + }, + ) + const schema = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => schemaResult, + }, + } satisfies StandardSchemaV1<{ name: string }, { name: string }> + const { runWithContext, pipeline } = getPipeline(formApi, [ + { + run: schema, + runOnSubmit: () => shouldRunOnSubmit, + triggers: [], + }, + ]) + + const staleValidation = runWithContext({ event: 'submit' }) + shouldRunOnSubmit = false + await runWithContext({ event: 'submit' }) + await staleValidation + + resolveSchema({ value: { name: 'stale' } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(pipeline[0]?.hasSchemaOutput).toBe(false) + expect(pipeline[0]?.schemaOutput).toBeUndefined() }) it('should validate form with a failing zod schema', async () => { @@ -1013,7 +1267,6 @@ describe('runFormValidatorPipeline', () => { const schema = z.object({ name: z .string() - // eslint-disable-next-line @typescript-eslint/require-await .refine(async (val) => val.length > 0, 'Name is required'), }) diff --git a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts index f52c86d081..5a6c1a5791 100644 --- a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts +++ b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts @@ -11,7 +11,7 @@ export const serverErrorOnUnmountedField = { } const validator = - field.form._options.validators?.[error.source.validatorIndex] + field.form._validatorInstances?.[error.source.validatorIndex]?.definition const hasServerTrigger = validator?.triggers.includes('server') if (!hasServerTrigger) return undefined diff --git a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts index 96434f900c..f8455af3da 100644 --- a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts +++ b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts @@ -2,9 +2,12 @@ import { getBy, isStandardSchema } from '@tanstack/form-core/internals' import { compareFieldPaths } from '../utils' import type { AnyInternalFieldApi, + AnyInternalValidatorInstance, FieldListenToFields, FieldWatchingFields, + FieldWatchingValidatorFields, InternalFieldState, + ValidationSourceErrorMap, } from '@tanstack/form-core/internals' import type { ValidationIssue } from '@tanstack/form-core' import type { @@ -41,26 +44,27 @@ function projectError( return error } -function appendErrors({ +function appendValidatorErrors({ destination, - errorBuckets, - errorSourceEvents, + errorMap, + validatorInstances, getSource, mode, }: { destination: Array - errorBuckets: Array> - errorSourceEvents: Array + errorMap: ValidationSourceErrorMap | null + validatorInstances: ReadonlyArray | null getSource: ( validatorIndex: number, sourceEvent: string, ) => DevtoolsFieldErrorSource mode: FieldErrorPayloadMode }): void { - errorBuckets.forEach((errors, validatorIndex) => { - if (errors.length === 0) return + validatorInstances?.forEach((validatorInstance, validatorIndex) => { + const errorState = errorMap?.get(validatorInstance) + if (!errorState) return - const sourceEvent = errorSourceEvents[validatorIndex] ?? 'unknown' + const { errors, sourceEvent } = errorState const source = getSource(validatorIndex, sourceEvent) for (const error of errors) { @@ -83,26 +87,27 @@ export function getDevtoolsFieldErrors( const errors: Array = [] const meta = state.meta - appendErrors({ + appendValidatorErrors({ destination: errors, - errorBuckets: meta._fieldValidatorErrors, - errorSourceEvents: meta._fieldValidatorErrorSourceEvents, + errorMap: meta._validationSourceErrors, + validatorInstances: field._validatorInstances, mode, getSource: (validatorIndex) => ({ scope: 'field', validatorIndex, - validatorType: getValidatorType(field._validators?.[validatorIndex]), + validatorType: getValidatorType( + field._validatorInstances?.[validatorIndex]?.definition, + ), }), }) - const groupErrors = meta._formGroupValidatorErrors - if (groupErrors) { + if (meta._validationSourceErrors) { const containingGroup = field._getFormGroup() - appendErrors({ + appendValidatorErrors({ destination: errors, - errorBuckets: groupErrors.errors, - errorSourceEvents: groupErrors.errorSourceEvents, + errorMap: meta._validationSourceErrors, + validatorInstances: containingGroup?._validatorInstances ?? null, mode, getSource: (validatorIndex) => ({ scope: 'formGroup', @@ -111,34 +116,40 @@ export function getDevtoolsFieldErrors( : '(unknown form group)', validatorIndex, validatorType: getValidatorType( - containingGroup?._options.validators?.[validatorIndex], + containingGroup?._validatorInstances?.[validatorIndex]?.definition, ), }), }) } - const formValidators = field.form._options.validators ?? [] - appendErrors({ + appendValidatorErrors({ destination: errors, - errorBuckets: meta._formValidatorErrors, - errorSourceEvents: meta._formValidatorErrorSourceEvents, + errorMap: meta._validationSourceErrors, + validatorInstances: field.form._validatorInstances, mode, - getSource: (validatorIndex, sourceEvent) => { - if ( - validatorIndex === formValidators.length && - sourceEvent === 'submit' - ) { - return { scope: 'onSubmit', validatorType: 'callback' } - } - - return { - scope: 'form', - validatorIndex, - validatorType: getValidatorType(formValidators[validatorIndex]), - } - }, + getSource: (validatorIndex) => ({ + scope: 'form', + validatorIndex, + validatorType: getValidatorType( + field.form._validatorInstances?.[validatorIndex]?.definition, + ), + }), }) + const onSubmitErrorState = meta._validationSourceErrors?.get( + field.form._onSubmitSource, + ) + if (onSubmitErrorState) { + const { errors: submitErrors, sourceEvent } = onSubmitErrorState + for (const error of submitErrors) { + errors.push({ + error: projectError(error, mode), + source: { scope: 'onSubmit', validatorType: 'callback' }, + sourceEvent, + }) + } + } + return errors } @@ -286,6 +297,63 @@ function addListenedToByRelations( }) } +function addValidatorListensToRelations( + relations: Map, + field: AnyInternalFieldApi, + identity: Pick, +): void { + field._validatorInstances?.forEach((validatorInstance, itemIndex) => { + validatorInstance.resolvedWatchFields?.forEach( + (sourceField, configuredPath) => { + addRelation( + relations, + sourceField, + getRelationCause( + 'validator', + itemIndex, + configuredPath, + sourceField.name, + ), + identity, + ) + }, + ) + }) +} + +function addValidatorListenedToByRelations( + relations: Map, + sourceField: AnyInternalFieldApi, + watchingFields: FieldWatchingValidatorFields | null, + identity: Pick, +): void { + watchingFields?.forEach((validatorInstances, watchingField) => { + if (watchingField._isKilled) return + + for (const validatorInstance of validatorInstances) { + const itemIndex = + watchingField._validatorInstances?.indexOf(validatorInstance) ?? -1 + if (itemIndex < 0) continue + + let configuredPath: string | undefined + validatorInstance.resolvedWatchFields?.forEach((field, path) => { + if (field === sourceField) configuredPath = path + }) + addRelation( + relations, + watchingField, + getRelationCause( + 'validator', + itemIndex, + configuredPath, + sourceField.name, + ), + identity, + ) + } + }) +} + function compareRelationCauses( left: DevtoolsFieldRelationCause, right: DevtoolsFieldRelationCause, @@ -321,12 +389,7 @@ function getDevtoolsFieldRelations( const listenedToBy = new Map() addListensToRelations(listensTo, field._listenToFields, 'listener', identity) - addListensToRelations( - listensTo, - field._validateOnFields, - 'validator', - identity, - ) + addValidatorListensToRelations(listensTo, field, identity) addListenedToByRelations( listenedToBy, field, @@ -335,12 +398,10 @@ function getDevtoolsFieldRelations( 'listener', identity, ) - addListenedToByRelations( + addValidatorListenedToByRelations( listenedToBy, field, field._watchingValidatorFields, - (watchingField) => watchingField._validateOnFields, - 'validator', identity, ) diff --git a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts index 392801940c..99e81709c5 100644 --- a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts +++ b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts @@ -1,19 +1,16 @@ -import type { AnyFieldValidator } from '@tanstack/form-core/internals' +import type { AnyInternalValidatorInstance } from '@tanstack/form-core/internals' import type { ValidatorsWithoutTriggersSuspicion } from '../../../eventClientTypes' import type { FieldDebugCase } from './types' -import type { FormGroupValidator, FormValidator } from '@tanstack/form-core' type ValidatorLocation = ValidatorsWithoutTriggersSuspicion['evidence']['validators'][number] function appendValidatorsWithoutTriggers( locations: Array, - validators: ReadonlyArray< - AnyFieldValidator | FormGroupValidator | FormValidator - > | null, + validatorInstances: ReadonlyArray | null, getLocation: (validatorIndex: number) => ValidatorLocation, ): void { - validators?.forEach((validator, validatorIndex) => { + validatorInstances?.forEach(({ definition: validator }, validatorIndex) => { if (validator.triggers.length === 0 && !validator.runOnMount) { locations.push(getLocation(validatorIndex)) } @@ -26,7 +23,7 @@ export const validatorsWithoutTriggers = { appendValidatorsWithoutTriggers( validators, - field._validators, + field._validatorInstances, (validatorIndex) => ({ scope: 'field', validatorIndex }), ) @@ -34,7 +31,7 @@ export const validatorsWithoutTriggers = { if (group) { appendValidatorsWithoutTriggers( validators, - group._options.validators ?? null, + group._validatorInstances, (validatorIndex) => ({ scope: 'formGroup', formGroupPath: String(group.name), @@ -44,7 +41,7 @@ export const validatorsWithoutTriggers = { } else { appendValidatorsWithoutTriggers( validators, - field.form._options.validators ?? null, + field.form._validatorInstances, (validatorIndex) => ({ scope: 'form', validatorIndex }), ) } diff --git a/packages/form-devtools/src/bridge/fields/index.ts b/packages/form-devtools/src/bridge/fields/index.ts index 708432ce2a..123701f9ae 100644 --- a/packages/form-devtools/src/bridge/fields/index.ts +++ b/packages/form-devtools/src/bridge/fields/index.ts @@ -37,15 +37,24 @@ interface FieldsController { function addForwardRelations( fields: Set, - relationGroups: - | AnyInternalFieldApi['_listenToFields'] - | AnyInternalFieldApi['_validateOnFields'], + relationGroups: AnyInternalFieldApi['_listenToFields'], ): void { relationGroups?.forEach((relations) => { for (const relation of relations) fields.add(relation.field) }) } +function addValidatorForwardRelations( + fields: Set, + field: AnyInternalFieldApi, +): void { + field._validatorInstances?.forEach((validatorInstance) => { + validatorInstance.resolvedWatchFields?.forEach((sourceField) => + fields.add(sourceField), + ) + }) +} + function addReverseRelations( fields: Set, relationGroups: @@ -63,7 +72,7 @@ function addRelationNeighborhood( ): void { fields.add(field) addForwardRelations(fields, field._listenToFields) - addForwardRelations(fields, field._validateOnFields) + addValidatorForwardRelations(fields, field) addReverseRelations(fields, field._watchingFields) addReverseRelations(fields, field._watchingValidatorFields) } diff --git a/packages/form-devtools/tests/bridgeComposition.test.ts b/packages/form-devtools/tests/bridgeComposition.test.ts index 5367cbffe5..e91527b6d4 100644 --- a/packages/form-devtools/tests/bridgeComposition.test.ts +++ b/packages/form-devtools/tests/bridgeComposition.test.ts @@ -34,7 +34,12 @@ describe('form devtools bridge composition', () => { const bridge = createFormDevtoolsBridge({ fields, mountedForms }) const removedFields = [{ field, previousPath: 'name' }] const dependencyChanges = [ - { sourceField: field, watchingField: field, watcherIndex: 0 }, + { + kind: 'listener' as const, + sourceField: field, + watchingField: field, + watcherIndex: 0, + }, ] bridge.mountForm?.(form) diff --git a/packages/form-devtools/tests/devtoolsBridge.test.ts b/packages/form-devtools/tests/devtoolsBridge.test.ts index 1a5713a436..424d62ceaa 100644 --- a/packages/form-devtools/tests/devtoolsBridge.test.ts +++ b/packages/form-devtools/tests/devtoolsBridge.test.ts @@ -5,6 +5,7 @@ import { import { describe, expect, it } from 'vitest' import { createFieldIdentityController } from '../src/bridge/fields/identity' import { getFieldRowsSnapshot } from '../src/bridge/fields/list' +import { setFieldValidatorErrors } from './testUtils' describe('form devtools bridge field snapshots', () => { it('includes mounted and unmounted fields and omits raw values', () => { @@ -67,19 +68,13 @@ describe('form devtools bridge field snapshots', () => { }) expect(getFieldRowsSnapshot(form, identity)[0]?.summary).toBeUndefined() - mountedField._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid name' }]], - })) + setFieldValidatorErrors(mountedField, [{ message: 'Invalid name' }]) expect(getFieldRowsSnapshot(form, identity)[0]?.summary).toEqual({ hasSelfErrors: true, validity: 'invalid', }) - mountedField._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[]], - })) + setFieldValidatorErrors(mountedField, []) expect(getFieldRowsSnapshot(form, identity)[0]?.summary).toBeUndefined() } finally { unregister() @@ -116,10 +111,7 @@ describe('form devtools bridge field snapshots', () => { { fieldId, path: 'name', isMounted: false }, ]) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid name' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Invalid name' }]) expect(getFieldRowsSnapshot(form, identity)).toEqual([ { fieldId, @@ -129,10 +121,7 @@ describe('form devtools bridge field snapshots', () => { }, ]) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[]], - })) + setFieldValidatorErrors(field, []) expect(getFieldRowsSnapshot(form, identity)).toEqual([ { fieldId, path: 'name', isMounted: false }, ]) @@ -148,10 +137,7 @@ describe('form devtools bridge field snapshots', () => { const group = new InternalFormGroupApi({ form, name: 'guestDetails' }) try { - child._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid field' }]], - })) + setFieldValidatorErrors(child, [{ message: 'Invalid field' }]) expect(parent.state.meta.isValid).toBe(false) expect(getFieldRowsSnapshot(form, identity)).toEqual([ @@ -177,10 +163,7 @@ describe('form devtools bridge field snapshots', () => { const unregister = field._register() try { - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid name' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Invalid name' }]) expect(field.meta.isValid).toBe(true) expect(field.meta.original.isValid).toBe(false) diff --git a/packages/form-devtools/tests/fieldDebugCases.test.ts b/packages/form-devtools/tests/fieldDebugCases.test.ts index 5409a1d323..cde9a5679c 100644 --- a/packages/form-devtools/tests/fieldDebugCases.test.ts +++ b/packages/form-devtools/tests/fieldDebugCases.test.ts @@ -5,6 +5,7 @@ import { import { describe, expect, it } from 'vitest' import { z } from 'zod' import { getFieldDebugSuspicions } from '../src/bridge/fields/fieldDebug' +import { setFieldValidatorErrors } from './testUtils' import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' import type { FieldDebugCase } from '../src/bridge/fields/fieldDebug' @@ -24,10 +25,7 @@ const schemaValidator = { } function setFieldError(field: AnyInternalFieldApi) { - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Invalid' }]) } describe('field debug cases', () => { diff --git a/packages/form-devtools/tests/fieldDetailsBridge.test.ts b/packages/form-devtools/tests/fieldDetailsBridge.test.ts index 33a4edeec7..a58d8aec2b 100644 --- a/packages/form-devtools/tests/fieldDetailsBridge.test.ts +++ b/packages/form-devtools/tests/fieldDetailsBridge.test.ts @@ -12,6 +12,11 @@ import { getDevtoolsFieldDetail } from '../src/bridge/fields/detailSnapshot' import { createFormDevtoolsBridge } from '../src/bridge/createBridge' import { formDevtoolsEventClient } from '../src/eventClient.lib' import { connectTestEventBus } from './testEventBus' +import { setFieldValidatorErrors } from './testUtils' +import type { + AnyInternalValidationSourceInstance, + ValidationSourceErrorState, +} from '@tanstack/form-core/internals' import type { DevtoolsFieldDetail, FieldDetailSubscriptionDescriptor, @@ -70,26 +75,73 @@ describe('field detail snapshots', () => { const subscription = descriptor('form', 'field') try { + const [fieldCallback, fieldSchema] = field._validatorInstances! + const groupSchema = group._validatorInstances![0]! + const [formCallback, formSchema] = form._validatorInstances! field._setMeta((meta) => ({ ...meta, - _fieldValidatorErrors: [ + _validationSourceErrors: new Map< + AnyInternalValidationSourceInstance, + ValidationSourceErrorState + >([ [ - { message: 'Field callback', code: 'field-code' } as never, - { message: 'Field callback second', code: 'field-code-2' } as never, + fieldCallback!, + { + errors: [ + { message: 'Field callback', code: 'field-code' } as never, + { + message: 'Field callback second', + code: 'field-code-2', + } as never, + ], + sourceEvent: 'change', + }, ], - [{ message: 'Field schema', path: ['name'] } as never], - ], - _fieldValidatorErrorSourceEvents: ['change', 'blur'], - _formGroupValidatorErrors: { - errors: [[{ message: 'Group schema', path: ['name'] } as never]], - errorSourceEvents: ['server'], - }, - _formValidatorErrors: [ - [{ message: 'Form callback', code: 'form-code' } as never], - [{ message: 'Form schema', path: ['profile', 'name'] } as never], - [{ message: 'Submit callback', code: 'submit-code' } as never], - ], - _formValidatorErrorSourceEvents: ['change', 'server', 'submit'], + [ + fieldSchema!, + { + errors: [{ message: 'Field schema', path: ['name'] } as never], + sourceEvent: 'blur', + }, + ], + [ + groupSchema, + { + errors: [{ message: 'Group schema', path: ['name'] } as never], + sourceEvent: 'server', + }, + ], + [ + formCallback!, + { + errors: [ + { message: 'Form callback', code: 'form-code' } as never, + ], + sourceEvent: 'change', + }, + ], + [ + formSchema!, + { + errors: [ + { + message: 'Form schema', + path: ['profile', 'name'], + } as never, + ], + sourceEvent: 'server', + }, + ], + [ + form._onSubmitSource, + { + errors: [ + { message: 'Submit callback', code: 'submit-code' } as never, + ], + sourceEvent: 'submit', + }, + ], + ]), })) const full = getDevtoolsFieldDetail(field, subscription, identity) @@ -587,11 +639,7 @@ describe('field detail bridge', () => { expect(details.at(-1)?.state.value).toBe('Ada') expect(details.at(-1)?.state.meta.isDirty).toBe(true) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Keep this field' }]], - _fieldValidatorErrorSourceEvents: ['change'], - })) + setFieldValidatorErrors(field, [{ message: 'Keep this field' }]) unregister() await new Promise((resolve) => setTimeout(resolve, 0)) field.handleChange('Grace') diff --git a/packages/form-devtools/tests/fieldErrorDebugCases.test.ts b/packages/form-devtools/tests/fieldErrorDebugCases.test.ts index 53188527b3..da726740dc 100644 --- a/packages/form-devtools/tests/fieldErrorDebugCases.test.ts +++ b/packages/form-devtools/tests/fieldErrorDebugCases.test.ts @@ -4,6 +4,7 @@ import { } from '@tanstack/form-core/internals' import { describe, expect, it } from 'vitest' import { getFieldErrorDebugSuspicions } from '../src/bridge/fields/debug' +import { setFieldValidatorErrors } from './testUtils' import type { FieldErrorDebugCase } from '../src/bridge/fields/debug' import type { DevtoolsFieldError } from '../src/eventClientTypes' @@ -47,10 +48,7 @@ describe('field error debug cases', () => { const unregister = field._register() try { - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Hidden error' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Hidden error' }]) expect(field.state.meta.errors).toEqual([]) expect(field.state.meta.original.errors).toHaveLength(1) @@ -77,10 +75,7 @@ describe('field error debug cases', () => { getFieldErrorDebugSuspicions({ field, error: callbackError }), ).toEqual([]) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Visible error' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Visible error' }]) expect(field.state.meta.errors).toHaveLength(1) expect( diff --git a/packages/form-devtools/tests/fieldGeneralDebugReportsBridge.test.ts b/packages/form-devtools/tests/fieldGeneralDebugReportsBridge.test.ts index 734dcd488c..38b0a4bb06 100644 --- a/packages/form-devtools/tests/fieldGeneralDebugReportsBridge.test.ts +++ b/packages/form-devtools/tests/fieldGeneralDebugReportsBridge.test.ts @@ -5,6 +5,7 @@ import { createFieldsController } from '../src/bridge/fields' import { createMountedFormsController } from '../src/bridge/forms/mountedForms' import { formDevtoolsEventClient } from '../src/eventClient.lib' import { connectTestEventBus } from './testEventBus' +import { setFieldValidatorErrors } from './testUtils' import type { FieldDebugReport } from '../src/eventClientTypes' const schemaValidator = { @@ -37,10 +38,7 @@ describe('general field debug report bridge', () => { (event) => reports.push(event.payload), ) - child._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Child error' }]], - })) + setFieldValidatorErrors(child, [{ message: 'Child error' }]) try { mountedForms.mountForm(form) @@ -71,10 +69,7 @@ describe('general field debug report bridge', () => { }, ]) - parent._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Parent error' }]], - })) + setFieldValidatorErrors(parent, [{ message: 'Parent error' }]) formDevtoolsEventClient.emit('field-debug-report-request', { requestId: 'parent-now-has-errors', formInstanceId, diff --git a/packages/form-devtools/tests/fieldListBridge.test.ts b/packages/form-devtools/tests/fieldListBridge.test.ts index 6c8ae25c03..6fb459b524 100644 --- a/packages/form-devtools/tests/fieldListBridge.test.ts +++ b/packages/form-devtools/tests/fieldListBridge.test.ts @@ -9,6 +9,7 @@ import { createMountedFormsController } from '../src/bridge/forms/mountedForms' import { createFormDevtoolsBridge } from '../src/bridge/createBridge' import { formDevtoolsEventClient } from '../src/eventClient.lib' import { connectTestEventBus } from './testEventBus' +import { setFieldValidatorErrors } from './testUtils' import type { FormDevtoolsEventMap } from '../src/eventClientTypes' type FieldListSnapshot = FormDevtoolsEventMap['field-list-snapshot'] @@ -99,10 +100,7 @@ describe('field list bridge', () => { await flushPatches() expect(patches).toHaveLength(patchCountAfterBlur) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid name' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Invalid name' }]) fields.updateField(field) await flushPatches() expect(field.meta.isValid).toBe(false) @@ -116,10 +114,7 @@ describe('field list bridge', () => { ], }) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[]], - })) + setFieldValidatorErrors(field, []) fields.updateField(field) await flushPatches() expect(field.meta.isValid).toBe(true) @@ -227,10 +222,7 @@ describe('field list bridge', () => { upsert: [{ fieldId, path: 'name', isMounted: false }], }) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[{ message: 'Invalid name' }]], - })) + setFieldValidatorErrors(field, [{ message: 'Invalid name' }]) fields.updateField(field) await flushPatches() expect(patches.at(-1)).toEqual({ @@ -243,10 +235,7 @@ describe('field list bridge', () => { ], }) - field._setMeta((meta) => ({ - ...meta, - _fieldValidatorErrors: [[]], - })) + setFieldValidatorErrors(field, []) fields.updateField(field) await flushPatches() expect(patches.at(-1)).toEqual({ diff --git a/packages/form-devtools/tests/testUtils.ts b/packages/form-devtools/tests/testUtils.ts new file mode 100644 index 0000000000..7376a1449f --- /dev/null +++ b/packages/form-devtools/tests/testUtils.ts @@ -0,0 +1,25 @@ +import { reconcileValidatorInstances } from '@tanstack/form-core/internals' +import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' +import type { ValidationIssue } from '@tanstack/form-core' + +export function setFieldValidatorErrors( + field: AnyInternalFieldApi, + errors: Array, + sourceEvent = 'change', +): void { + field._validatorInstances ??= reconcileValidatorInstances({ + definitions: [{ run: () => null, triggers: ['change'] }], + instances: null, + owner: field, + scope: 'field', + }) + const validatorInstance = field._validatorInstances![0]! + + field._setMeta((meta) => ({ + ...meta, + _validationSourceErrors: + errors.length > 0 + ? new Map([[validatorInstance, { errors, sourceEvent }]]) + : null, + })) +}