From ff6dd1237f83648e8a48f7216c21a6de63da4f01 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:30:12 +0200 Subject: [PATCH 01/12] refactor: add unconnected validator instance class --- .../form-core/src/ValidatorInstance.lib.ts | 293 ++++++++++++++++++ packages/form-core/src/internals.ts | 1 + .../form-core/tests/ValidatorInstance.spec.ts | 262 ++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 packages/form-core/src/ValidatorInstance.lib.ts create mode 100644 packages/form-core/tests/ValidatorInstance.spec.ts diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts new file mode 100644 index 0000000000..6713b335ff --- /dev/null +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -0,0 +1,293 @@ +import { LiteDebouncer } from '@tanstack/pacer-lite' +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 interface InternalValidatorInstanceOptions< + out TDefinition extends InternalValidatorDefinition, + out TOwner, +> { + definition: TDefinition + owner: TOwner + scope: ValidatorScope +} + +/** + * 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, +> { + /** The validation boundary that owns this installed validator occurrence. */ + readonly owner: TOwner + /** The form, group, or field scope in which the validator executes. */ + readonly scope: ValidatorScope + + /** 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 most recently stored Standard Schema output. + * + * Consult `hasSchemaOutput` because `undefined` can itself be a stored output. + */ + schemaOutput: TSchemaOutput | undefined + /** Whether `schemaOutput` has been assigned, including to `undefined`. */ + hasSchemaOutput = false + /** + * Targets currently receiving errors routed from this validator. + * + * The set is allocated on first use and returns to `null` when empty. + */ + errorTargets: Set | null = null + /** + * 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 + /** + * Whether this instance has been permanently disposed. + * + * Mutation helpers become no-ops after disposal. + */ + disposed = false + + /** Creates the runtime state for one installed validator occurrence. */ + constructor({ + definition, + owner, + scope, + }: InternalValidatorInstanceOptions) { + this.definition = definition + this.owner = owner + this.scope = scope + } + + /** + * 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 the latest Standard Schema output and marks it as present. + * + * An explicit `undefined` is still considered a stored output. The operation + * is ignored after disposal. + * + * @param schemaOutput - The output produced by the validator's schema. + */ + setSchemaOutput(schemaOutput: TSchemaOutput): void { + if (this.disposed) return + + this.schemaOutput = schemaOutput + this.hasSchemaOutput = true + } + + /** Clears the stored schema output and its presence marker. */ + clearSchemaOutput(): void { + if (this.disposed) return + + this._clearSchemaOutput() + } + + /** + * Records a target receiving errors from this validator. + * + * The backing set is allocated lazily. The operation is ignored after disposal. + * + * @param errorTarget - The target receiving routed validation errors. + */ + addErrorTarget(errorTarget: TErrorTarget): void { + if (this.disposed) return + + if (!this.errorTargets) { + this.errorTargets = new Set() + } + this.errorTargets.add(errorTarget) + } + + /** + * Stops tracking an error target. + * + * @param errorTarget - The target whose routed-error association is removed. + */ + deleteErrorTarget(errorTarget: TErrorTarget): void { + if (this.disposed) return + + this.errorTargets?.delete(errorTarget) + } + + /** + * 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) + } + + /** Marks mount validation as completed for this occurrence. */ + markMountValidationRan(): void { + if (this.disposed) return + + this.didRunOnMount = true + } + + /** + * 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 { + if (this.disposed) return + + this._cancelExecution() + this._clearSchemaOutput() + this.errorTargets = null + } + + /** + * Permanently disposes this validator occurrence and its runtime resources. + * + * Disposal cancels execution, releases outputs and collections, clears the + * mount marker, and is idempotent. Mutation helpers subsequently become no-ops. + */ + dispose(): void { + if (this.disposed) return + + this._cancelExecution() + this._clearSchemaOutput() + this.errorTargets = null + this.resolvedWatchFields = null + this.didRunOnMount = false + this.disposed = true + } + + /** 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 + } +} diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index 2e7ac143a4..0d8fcb9e82 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -8,6 +8,7 @@ export * from './utils.lib' export * from './types.lib' export * from './FieldApi/RootFieldApi.lib' export * from './validation.lib' +export * from './ValidatorInstance.lib' export * from './listeners.lib' export * from './FieldApi/linked-fields.lib' export * from './standardSchema.lib' diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts new file mode 100644 index 0000000000..af3f0af0ce --- /dev/null +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { LiteDebouncer } from '@tanstack/pacer-lite' +import { InternalValidatorInstance } from '../src/ValidatorInstance.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('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('field') + 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') + + 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('output') + 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(undefined) + 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('output') + 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('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('output') + instance.addErrorTarget('target') + instance.setResolvedWatchField('source', { name: 'source' }) + instance.markMountValidationRan() + debouncer?.maybeExecute('cancelled') + + instance.dispose() + instance.dispose() + 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).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('ignored') + 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() + }) +}) From 5c5f346e7d29330c1f640debd800e86be5d3905b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:39:39 +0200 Subject: [PATCH 02/12] refactor: store new validator instances in state --- .../form-core/src/FieldApi/FieldApi.lib.ts | 21 +++++ .../form-core/src/FieldApi/fieldTree.lib.ts | 2 + packages/form-core/src/FormApi/FormApi.lib.ts | 31 ++++-- .../src/FormGroupApi/FormGroupApi.lib.ts | 29 ++++++ .../form-core/src/ValidatorInstance.lib.ts | 86 +++++++++++++++++ .../tests/FieldApi/Lifecycle.spec.ts | 56 +++++++++++ .../tests/FieldApi/validation.spec.ts | 4 +- .../form-core/tests/FormApi/lifecycle.spec.ts | 43 ++++++++- .../tests/FormGroupApi/FormGroupApi.spec.ts | 69 ++++++++++++++ .../form-core/tests/ValidatorInstance.spec.ts | 94 ++++++++++++++++++- 10 files changed, 421 insertions(+), 14 deletions(-) diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 927e40dd38..e43780579e 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -11,6 +11,7 @@ import { } from '../validation.lib' import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' +import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { attachWatchingListenerField, attachWatchingValidatorField, @@ -52,6 +53,7 @@ import type { FieldUpdateOptions, Updater } from '../types.public' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { ReadonlyAtom } from '@tanstack/store' +import type { InternalValidatorInstances } from '../ValidatorInstance.lib' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { ErrorVisibility, @@ -308,6 +310,11 @@ export class InternalFieldApi< _defaultValueCache: DefaultValueCacheEntry | null = null _atoms: FieldAtoms _validators: Array | null + /** Stable runtime instances correlated with `_validators` by slot. */ + _validatorInstances: InternalValidatorInstances< + AnyFieldValidator, + InternalFieldApi + > _listeners: Array | null _errorVisibility: ErrorVisibility | undefined _errorBoundary: boolean @@ -517,6 +524,12 @@ export class InternalFieldApi< reconciledValidators.attach.forEach(attachWatchingValidatorField) this._validators = reconciledValidators.items this._validateOnFields = reconciledValidators.listenToFields + this._validatorInstances = reconcileValidatorInstances({ + definitions: this._validators, + instances: null, + owner: this, + scope: 'field', + }) } _update(options: Omit) { @@ -545,6 +558,7 @@ export class InternalFieldApi< : null if (options.validators) { + const previousValidators = this._validators const reconciledValidators = reconcileWatchedValidatorFields({ field: this, prevListenToFields: this._validateOnFields, @@ -559,6 +573,13 @@ export class InternalFieldApi< this._validators = reconciledValidators.items this._validateOnFields = reconciledValidators.listenToFields + this._validatorInstances = reconcileValidatorInstances({ + definitions: this._validators, + previousDefinitions: previousValidators, + instances: this._validatorInstances, + owner: this, + scope: 'field', + }) dependencyChanges?.push( ...reconciledValidators.attach, ...reconciledValidators.detach, diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index d30fdbc53d..fa390bd325 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -375,6 +375,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) } diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index e6bd0edab8..ce4a75c63c 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -37,6 +37,7 @@ import { import { runFormListenerPipeline } from '../listeners.lib' import { applyServerState } from '../ssr.lib' import { devtools } from '../devtoolsBridge.lib' +import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { runSubmissionProcess } from './handleSubmit.lib' import { ArrayMethods } from './array-methods.lib' import { @@ -84,6 +85,7 @@ import type { } from '../validation.public' import type { FormListenerTriggers } from '../listeners.public' import type { ServerFormState } from '../ssr.public' +import type { InternalValidatorInstances } from '../ValidatorInstance.lib' export interface FormMetaAtoms { isDirty: Atom @@ -220,6 +222,11 @@ export class InternalFormApi< _fieldRootNode: InternalRootFieldApi _defaultValueCache: DefaultValueCacheEntry | null = null _options: InternalFormOptions + /** Stable runtime instances correlated with `_options.validators` by slot. */ + _validatorInstances: InternalValidatorInstances< + TFormValidators[number], + InternalFormApi + > _lastUpdateDefaultValues: TFormData _pipelineCache: PipelineCache _schemaOutputs: Array = [] @@ -300,6 +307,12 @@ export class InternalFormApi< this.atom = createAtom(() => getFormStateSnapshot(this), { compare: compareFormStateSnapshots, }) + this._validatorInstances = reconcileValidatorInstances({ + definitions: this._options.validators, + instances: null, + owner: this, + scope: 'form', + }) applyServerState( this, @@ -359,6 +372,7 @@ export class InternalFormApi< cancelPipelineCache(this._pipelineCache) this._pipelineCache = createPipelineCache() + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) this._schemaOutputs = [] this._defaultValueCache = null @@ -411,13 +425,13 @@ 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({ + definitions: this._options.validators, + previousDefinitions: oldOptions.validators ?? null, + instances: this._validatorInstances, + owner: this, + scope: 'form', + }) if (didDefaultValuesChange) { batch(() => { @@ -492,6 +506,9 @@ export class InternalFormApi< cancelPipelineCache(current._pipelineCache) current._pipelineCache = null } + current._validatorInstances?.forEach((instance) => + instance.resetRuntime(), + ) current._setMeta(() => defaultInternalBaseFieldMeta) } }) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 93271cc56b..99c049874d 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -26,6 +26,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 { @@ -57,6 +58,7 @@ import type { ValidationIssue, } from '../validation.public' import type { ReadonlyAtom } from '@tanstack/store' +import type { InternalValidatorInstances } from '../ValidatorInstance.lib' interface FormGroupValidationOutcome { errors: Array> @@ -100,6 +102,17 @@ export class InternalFormGroupApi< TGroupValidators, TFormErrorTypes > + /** Stable runtime instances correlated with `_options.validators` by slot. */ + _validatorInstances: InternalValidatorInstances< + TGroupValidators[number], + InternalFormGroupApi< + TFormData, + TGroupName, + TGroupValue, + TGroupValidators, + TFormErrorTypes + > + > atom: ReadonlyAtom< FormGroupState> > @@ -141,6 +154,12 @@ export class InternalFormGroupApi< this._groupField = this.form._getOrCreateFieldApi({ name: options.name }) this._groupField._setFormGroup(this) this._pipelineCache = createPipelineCache() + this._validatorInstances = reconcileValidatorInstances({ + definitions: this._options.validators, + instances: null, + owner: this, + scope: 'group', + }) const groupMetaMarkers: DerivedMetaMarkers = { source: undefined, @@ -250,7 +269,15 @@ export class InternalFormGroupApi< TFormErrorTypes >, ) => { + const previousValidators = this._options.validators this._options = options + this._validatorInstances = reconcileValidatorInstances({ + definitions: this._options.validators, + previousDefinitions: previousValidators ?? null, + instances: this._validatorInstances, + owner: this, + scope: 'group', + }) } mount = (): void => { @@ -722,6 +749,7 @@ export class InternalFormGroupApi< reset = () => { this._cancelValidation() + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) this._schemaOutputs = [] this.form._atoms.values.set((prev: TFormData) => setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), @@ -747,6 +775,7 @@ export class InternalFormGroupApi< _cleanup() { this._cancelValidation() + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) this._schemaOutputs = [] batch(() => { this._isSubmitting.set(false) diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts index 6713b335ff..a0e72df7fa 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -21,6 +21,33 @@ export interface InternalValidatorInstanceOptions< scope: ValidatorScope } +/** Stable runtime instances correlated with validator definitions by slot. */ +export type InternalValidatorInstances< + TDefinition extends InternalValidatorDefinition, + TOwner, +> = Array> | null + +export interface ReconcileValidatorInstancesOptions< + TDefinition extends InternalValidatorDefinition, + TOwner, +> { + /** 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 + /** The validation boundary that owns every reconciled instance. */ + owner: TOwner + /** The form, group, or field scope shared by the reconciled instances. */ + scope: ValidatorScope +} + /** * Runtime state owned by one installed validator occurrence. */ @@ -291,3 +318,62 @@ export class InternalValidatorInstance< 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, +>({ + definitions, + previousDefinitions, + instances, + owner, + scope, +}: ReconcileValidatorInstancesOptions< + TDefinition, + TOwner +>): InternalValidatorInstances { + 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()) + return null + } + + const nextInstances = instances ?? [] + + definitions.forEach((definition, index) => { + const instance = nextInstances[index] + + if (instance) { + instance.updateDefinition(definition) + } else { + nextInstances[index] = new InternalValidatorInstance({ + definition, + owner, + scope, + }) + } + }) + + for (let index = definitions.length; index < nextInstances.length; index++) { + nextInstances[index]?.dispose() + } + nextInstances.length = definitions.length + + return nextInstances +} diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 65a1005904..3af94d9399 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -31,6 +31,62 @@ 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('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('output') + + 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: '' } }) diff --git a/packages/form-core/tests/FieldApi/validation.spec.ts b/packages/form-core/tests/FieldApi/validation.spec.ts index 6a75b589e9..296158afe8 100644 --- a/packages/form-core/tests/FieldApi/validation.spec.ts +++ b/packages/form-core/tests/FieldApi/validation.spec.ts @@ -378,9 +378,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(warn).toHaveBeenCalled() warn.mockRestore() }) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index 04bcfb715e..f35ad38614 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -158,12 +158,49 @@ describe('form - lifecycle', () => { ], }) - expect(warn).toHaveBeenCalledWith( - 'TanStack Form: The length of the validator array should not change after initialization', - ) + expect(warn).toHaveBeenCalled() 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('form') + expect(instance?.revision).toBe(1) + }) + + 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('output') + + 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() diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 4a4dc4466f..dde2a4f7cd 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -314,6 +314,75 @@ 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('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).toHaveBeenCalled() + 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('output') + + 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: { diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts index af3f0af0ce..7c3d6c6c2c 100644 --- a/packages/form-core/tests/ValidatorInstance.spec.ts +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { LiteDebouncer } from '@tanstack/pacer-lite' -import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' +import { + InternalValidatorInstance, + reconcileValidatorInstances, +} from '../src/ValidatorInstance.lib' type TestDebouncedFn = (value: string) => void @@ -260,3 +263,92 @@ describe('InternalValidatorInstance', () => { 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('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('group') + + expect( + reconcileValidatorInstances({ + definitions: null, + instances: expanded, + owner, + scope: 'group', + }), + ).toBeNull() + expect(firstInstance?.disposed).toBe(true) + expect(secondInstance?.disposed).toBe(true) + }) +}) From 0354148eb6138c1cbbcf988d82c9b7e4223914c1 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:22:22 +0200 Subject: [PATCH 03/12] fix: tighten schema output type --- packages/form-core/src/validation.public.ts | 17 ++++- packages/form-core/tests/validation.test-d.ts | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 655a4621d5..457fb865ed 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -636,6 +636,19 @@ type TryGetSchemaOutput = TValidator extends { ? TOutput : undefined +type TryGetSubmitSchemaOutput = + TryGetSchemaOutput extends infer TOutput + ? TValidator extends { readonly runOnSubmit: infer TRunOnSubmit } + ? [TRunOnSubmit] extends [false] + ? undefined // User explicitly set runOnSubmit: false -> guaranteed undefined + : TRunOnSubmit extends (...args: Array) => boolean + ? TOutput | undefined // Callback could dynamically be true or false -> union + : false extends TRunOnSubmit + ? TOutput | undefined // the explicit variable is boolean, so also dynamic -> union + : TOutput + : TOutput // default, which is guaranteed present + : never + type ValidatorTriggers = TValidator extends { readonly triggers: infer TTriggers } @@ -667,9 +680,7 @@ type MappedSchemaOutputs> = { [K in keyof TValidators]: TValidators[K] extends { readonly run: any } - ? TValidators[K] extends { readonly runOnSubmit: false } - ? undefined - : TryGetSchemaOutput + ? TryGetSubmitSchemaOutput : never } diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index bc1b8c0b9d..71b099acab 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -1285,6 +1285,72 @@ describe('validator type transforms', () => { >() }) + it('makes schema outputs optional when runOnSubmit is dynamic', () => { + const dynamicRunOnSubmit = true as boolean + const vs = defineFormValidators([ + { + run: z.object({ name: z.string() }), + triggers: [], + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: true, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: false, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: dynamicRunOnSubmit, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => true, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => false, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => dynamicRunOnSubmit, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: undefined, + }, + ]) + + type Output = { name: string } + type Outputs = ToFormSchemaOutputs + type Expected = readonly [ + // An omitted runOnSubmit defaults to true, so the output is guaranteed. + Output, + // A literal true always runs the validator during submit. + Output, + // A literal false always skips the validator during submit. + undefined, + // A broad boolean may skip the validator at runtime. + Output | undefined, + // Predicates currently always assume dynamic results. Types are here if you plan to tighten it in the future. + Output | undefined, + Output | undefined, + Output | undefined, + // An explicit undefined receives the same true default as an omitted property. + Output, + ] + + expectTypeOf().toEqualTypeOf() + }) + it('should transform field validators', () => { const vs = defineFieldValidators([ { From c4281a3189a46e1f65a5202a57fca05bab2d8bf3 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:54:01 +0200 Subject: [PATCH 04/12] refactor: use stable validator reference instead of index --- .../form-core/src/FieldApi/FieldApi.lib.ts | 274 ++-- .../form-core/src/FieldApi/fieldState.lib.ts | 49 +- .../form-core/src/FieldApi/fieldTree.lib.ts | 155 +- .../src/FieldApi/linked-fields.lib.ts | 132 +- packages/form-core/src/FormApi/FormApi.lib.ts | 318 ++-- .../form-core/src/FormApi/formState.lib.ts | 169 +-- .../form-core/src/FormApi/handleSubmit.lib.ts | 39 +- .../src/FormGroupApi/FormGroupApi.lib.ts | 333 +++-- .../src/ValidationSourceInstance.lib.ts | 105 ++ .../form-core/src/ValidatorInstance.lib.ts | 188 +-- packages/form-core/src/devtoolsBridge.lib.ts | 19 +- packages/form-core/src/internals.ts | 3 +- packages/form-core/src/listeners.lib.ts | 6 +- packages/form-core/src/ssr.lib.ts | 70 +- packages/form-core/src/utils.lib.ts | 33 +- packages/form-core/src/validation.lib.ts | 1294 ----------------- .../form-core/src/validation/errors.lib.ts | 257 ++++ .../form-core/src/validation/execution.lib.ts | 463 ++++++ packages/form-core/src/validation/index.ts | 34 + .../form-core/src/validation/mount.lib.ts | 359 +++++ .../form-core/src/validation/pipeline.lib.ts | 348 +++++ .../tests/FieldApi/Lifecycle.spec.ts | 48 +- .../form-core/tests/FieldApi/meta.spec.ts | 127 +- .../tests/FieldApi/validation.spec.ts | 31 +- .../form-core/tests/FormApi/lifecycle.spec.ts | 64 +- .../tests/FormApi/submission-handling.spec.ts | 29 + .../tests/FormApi/validation.spec.ts | 15 +- .../tests/FormGroupApi/FormGroupApi.spec.ts | 77 +- .../form-core/tests/ValidatorInstance.spec.ts | 91 +- .../form-core/tests/serverValidate.spec.ts | 75 +- .../form-core/tests/validation-errors.test.ts | 180 +++ ...on.test.ts => validation-pipeline.test.ts} | 451 +++--- .../form-core/tests/validation-public.test.ts | 90 ++ .../debug/serverErrorOnUnmountedField.ts | 2 +- .../src/bridge/fields/detailSnapshot.ts | 151 +- .../fieldDebug/validatorsWithoutTriggers.ts | 15 +- .../form-devtools/src/bridge/fields/index.ts | 17 +- .../tests/bridgeComposition.test.ts | 7 +- .../tests/devtoolsBridge.test.ts | 31 +- .../tests/fieldDebugCases.test.ts | 6 +- .../tests/fieldDetailsBridge.test.ts | 90 +- .../tests/fieldErrorDebugCases.test.ts | 11 +- .../fieldGeneralDebugReportsBridge.test.ts | 11 +- .../tests/fieldListBridge.test.ts | 21 +- packages/form-devtools/tests/testUtils.ts | 25 + 45 files changed, 3745 insertions(+), 2568 deletions(-) create mode 100644 packages/form-core/src/ValidationSourceInstance.lib.ts delete mode 100644 packages/form-core/src/validation.lib.ts create mode 100644 packages/form-core/src/validation/errors.lib.ts create mode 100644 packages/form-core/src/validation/execution.lib.ts create mode 100644 packages/form-core/src/validation/index.ts create mode 100644 packages/form-core/src/validation/mount.lib.ts create mode 100644 packages/form-core/src/validation/pipeline.lib.ts create mode 100644 packages/form-core/tests/validation-errors.test.ts rename packages/form-core/tests/{validation.test.ts => validation-pipeline.test.ts} (81%) create mode 100644 packages/form-core/tests/validation-public.test.ts create mode 100644 packages/form-devtools/tests/testUtils.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index e43780579e..f5ccb95d35 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1,14 +1,13 @@ import { batch, createAtom } from '@tanstack/store' import { callUpdater, createPipelineCache, evaluate, getBy } from '../utils.lib' import { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, + clearValidationSourceErrorsFromEvent, isValidationTriggerEnabled, parseValidationResult, runFieldMountValidatorPipeline, runFieldValidatorPipeline, - setIndexedError, -} from '../validation.lib' + setValidationSourceError, +} from '../validation' import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' @@ -47,13 +46,17 @@ import type { NameSegment, NameSegments, PipelineCache } from '../utils.lib' import type { FieldValidatorPipelineResult, PipelineResult, -} from '../validation.lib' +} from '../validation' 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 { InternalValidatorInstances } from '../ValidatorInstance.lib' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { ErrorVisibility, @@ -275,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, @@ -309,11 +301,12 @@ export class InternalFieldApi< _childrenMap: Map = new Map() _defaultValueCache: DefaultValueCacheEntry | null = null _atoms: FieldAtoms - _validators: Array | null - /** Stable runtime instances correlated with `_validators` by slot. */ + /** Stable runtime instances for this field's validator definitions. */ _validatorInstances: InternalValidatorInstances< AnyFieldValidator, - InternalFieldApi + AnyInternalFieldApi, + AnyInternalFieldApi, + AnyInternalFieldApi > _listeners: Array | null _errorVisibility: ErrorVisibility | undefined @@ -328,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 /** @@ -403,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 } @@ -490,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 @@ -501,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, @@ -516,20 +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 - this._validatorInstances = reconcileValidatorInstances({ - definitions: this._validators, - instances: null, - owner: this, - scope: 'field', - }) } _update(options: Omit) { @@ -553,16 +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._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, }) @@ -571,15 +594,6 @@ export class InternalFieldApi< ) reconciledValidators.attach.forEach(attachWatchingValidatorField) - this._validators = reconciledValidators.items - this._validateOnFields = reconciledValidators.listenToFields - this._validatorInstances = reconcileValidatorInstances({ - definitions: this._validators, - previousDefinitions: previousValidators, - instances: this._validatorInstances, - owner: this, - scope: 'field', - }) dependencyChanges?.push( ...reconciledValidators.attach, ...reconciledValidators.detach, @@ -700,7 +714,7 @@ export class InternalFieldApi< event: 'change' | 'blur' | 'submit', options?: { onResult?: boolean - onlyRunValidatorIndeces?: Array | null + onlyRunValidatorInstances?: ReadonlySet | null _startValidation?: () => () => void }, ): Promise { @@ -711,7 +725,7 @@ export class InternalFieldApi< thrownError: null, } - const validators = this._validators + const validators = this._validatorInstances if (!validators) return { @@ -741,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() @@ -773,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() @@ -960,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 @@ -973,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) { @@ -1005,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) { @@ -1014,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() @@ -1026,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 fa390bd325..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, @@ -407,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) { @@ -458,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 @@ -502,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 ce4a75c63c..8acbba1195 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -25,23 +25,24 @@ import { } from '../FieldApi/fieldTraversal.lib' import { defaultInternalBaseFieldMeta } from '../FieldApi/fieldState.lib' import { - clearIndexedErrorsFromSource, + clearValidationSourceErrorsFromEvent, isErrorResult, isValidationTriggerEnabled, parseValidationResult, reconcileRoutedFieldErrors, runFormMountValidatorPipeline, runFormValidatorPipeline, - setIndexedError, -} from '../validation.lib' + 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, @@ -55,10 +56,7 @@ import type { import type { FormErrorMeta } from './formState.lib' import type { DeepKeys } from '../deep-keys.public' import type { PipelineCache } from '../utils.lib' -import type { - FormValidatorPipelineResult, - PipelineResult, -} from '../validation.lib' +import type { FormValidatorPipelineResult, PipelineResult } from '../validation' import type { AnyFieldApiOptions, AnyInternalFieldApi, @@ -78,6 +76,7 @@ import type { Updater } from '../types.public' import type { FormValidateResult, FormValidationError, + FormValidator, FormValidators, ToFormErrorTypes, ValidationIssue, @@ -85,7 +84,11 @@ import type { } from '../validation.public' import type { FormListenerTriggers } from '../listeners.public' import type { ServerFormState } from '../ssr.public' -import type { InternalValidatorInstances } from '../ValidatorInstance.lib' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' +import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' export interface FormMetaAtoms { isDirty: Atom @@ -95,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. @@ -124,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), @@ -152,6 +142,11 @@ function createInitialFormMetaAtoms(validatorCount: number): FormMetaAtoms { } export type AnyInternalFormApi = InternalFormApi +export type InternalFormValidatorInstance = InternalValidatorInstance< + FormValidator, + AnyInternalFormApi, + AnyInternalFieldApi +> type InternalFormOptions< TFormData, @@ -225,11 +220,16 @@ export class InternalFormApi< /** Stable runtime instances correlated with `_options.validators` by slot. */ _validatorInstances: InternalValidatorInstances< TFormValidators[number], - InternalFormApi + 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< @@ -295,23 +295,32 @@ 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({ + this._validatorInstances = reconcileValidatorInstances< + TFormValidators[number], + AnyInternalFormApi, + AnyInternalFieldApi + >({ definitions: this._options.validators, instances: null, owner: this, scope: 'form', + onBeforeDispose: (validatorInstance) => + this._removeValidatorInstance(validatorInstance), }) applyServerState( @@ -336,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, }) } @@ -373,7 +370,7 @@ export class InternalFormApi< cancelPipelineCache(this._pipelineCache) this._pipelineCache = createPipelineCache() this._validatorInstances?.forEach((instance) => instance.resetRuntime()) - this._schemaOutputs = [] + this._onSubmitSource.resetRuntime() this._defaultValueCache = null batch(() => { @@ -384,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) @@ -425,12 +413,18 @@ export class InternalFormApi< formId: options.formId ?? oldOptions.formId, } - this._validatorInstances = reconcileValidatorInstances({ + 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) { @@ -657,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, { @@ -683,26 +666,37 @@ 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 { + 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() + } + _tryGetFieldApi( nameOrSegments: string | Array, ): AnyInternalFieldApi | null { @@ -770,16 +764,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, ) @@ -788,23 +781,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, ) @@ -813,35 +804,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, ) @@ -849,8 +853,7 @@ export class InternalFormApi< return { ...prev, - _formValidatorErrors: clearedErrors.errors, - _formValidatorErrorSourceEvents: clearedErrors.errorSourceEvents, + _validationSourceErrors: clearedErrors.errorMap, } }) field._pruneIfUnused() @@ -860,9 +863,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( @@ -870,27 +872,79 @@ export class InternalFormApi< ) batch(() => { - this._setFormValidatorError( - result.validatorIndex, + this._setFormValidationSourceError( + validatorInstance, parsedResult.self ?? [], sourceEvent, ) - const fieldErrors = [...this._atoms.meta.fieldErrors.get()] - const oldFieldRefs = fieldErrors[result.validatorIndex] + const oldFieldRefs = validatorInstance.errorTargets ?? undefined const { fieldRefs, affectedFields, didFieldRefsChange } = reconcileRoutedFieldErrors( - result.validatorIndex, + validatorInstance, resolvedFieldErrors, oldFieldRefs, - (field, index, errors) => - this._setFieldValidatorError(field, index, errors, sourceEvent), - (field, index) => this._clearFieldValidatorError(field, index), + (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 oldFieldRefs = this._onSubmitSource.errorTargets ?? undefined + const { fieldRefs, affectedFields, didFieldRefsChange } = + reconcileRoutedFieldErrors( + this._onSubmitSource, + resolvedFieldErrors, + oldFieldRefs, + (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) { @@ -902,9 +956,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({ @@ -935,7 +995,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 7e5ad6040a..59b3fb861f 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -1,9 +1,8 @@ import { batch } from '@tanstack/store' import { - clearIndexedErrorsFromSource, - hasIndexedErrorFromSource, - hasIndexedErrors, -} from '../validation.lib' + clearValidationSourceErrorsFromEvent, + getValidationSourceErrors, +} from '../validation' import type { FormState } from './FormApi.public' import type { InternalFormApi } from './FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.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 d5907202b8..c84aa8a749 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -1,5 +1,5 @@ import { batch } from '@tanstack/store' -import { isErrorResult } from '../validation.lib' +import { isErrorResult } from '../validation' import { parseStandardSchemaIssues } from '../standardSchema.lib' import { isNotNil } from '../utils.lib' import type { InternalFormApi } from './FormApi.lib' @@ -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()) { @@ -215,14 +197,7 @@ export async function runSubmissionProcess( submissionData.hasFailed = true errorResults.push(submissionData.submitError) - form._processValidationResult( - { - validatorIndex: form._options.validators?.length ?? 0, - result: submissionData.submitError, - schemaResult: null, - }, - 'submit', - ) + form._processSubmitValidationResult(submissionData.submitError, 'submit') } }) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 99c049874d..439591ce6d 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -1,23 +1,16 @@ 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, -} from '../validation.lib' + setValidationSourceError, +} from '../validation' import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' import { visitFieldSubtree } from '../FieldApi/fieldTraversal.lib' import { @@ -35,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, @@ -58,18 +49,16 @@ import type { ValidationIssue, } from '../validation.public' import type { ReadonlyAtom } from '@tanstack/store' -import type { InternalValidatorInstances } from '../ValidatorInstance.lib' +import type { + InternalValidatorInstance, + InternalValidatorInstances, +} from '../ValidatorInstance.lib' interface FormGroupValidationOutcome { errors: Array> hasException: boolean } -const emptyFormGroupFieldErrorMeta: FormGroupFieldErrorMeta = { - errors: [], - errorSourceEvents: [], -} - export type AnyInternalFormGroupApi = InternalFormGroupApi< any, any, @@ -77,6 +66,11 @@ export type AnyInternalFormGroupApi = InternalFormGroupApi< any, any > +export type InternalGroupValidatorInstance = InternalValidatorInstance< + FormGroupValidator, + AnyInternalFormGroupApi, + AnyInternalFieldApi +> export class InternalFormGroupApi< TFormData, @@ -105,21 +99,12 @@ export class InternalFormGroupApi< /** Stable runtime instances correlated with `_options.validators` by slot. */ _validatorInstances: InternalValidatorInstances< TGroupValidators[number], - InternalFormGroupApi< - TFormData, - TGroupName, - TGroupValue, - TGroupValidators, - TFormErrorTypes - > + 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) @@ -153,8 +138,11 @@ 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({ + this._validatorInstances = reconcileValidatorInstances< + TGroupValidators[number], + AnyInternalFormGroupApi, + AnyInternalFieldApi + >({ definitions: this._options.validators, instances: null, owner: this, @@ -183,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 = [] } @@ -253,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() } @@ -271,25 +261,37 @@ export class InternalFormGroupApi< ) => { const previousValidators = this._options.validators this._options = options - this._validatorInstances = reconcileValidatorInstances({ + 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'), }) @@ -362,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, @@ -439,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 }) } @@ -490,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() } @@ -529,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) } } } @@ -632,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() @@ -645,8 +655,7 @@ export class InternalFormGroupApi< const results = await runValidatorPipeline< FormGroupValidateResult >({ - pipeline: pipeline as ReadonlyArray>, - cache: this._pipelineCache, + pipeline, context: { scope: 'group', event: signal, @@ -730,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 { @@ -749,8 +761,6 @@ export class InternalFormGroupApi< reset = () => { this._cancelValidation() - this._validatorInstances?.forEach((instance) => instance.resetRuntime()) - this._schemaOutputs = [] this.form._atoms.values.set((prev: TFormData) => setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), ) @@ -771,17 +781,20 @@ export class InternalFormGroupApi< this._submissionAttempts.set(0) this._clearRoutedErrors() }) + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) } _cleanup() { this._cancelValidation() - this._validatorInstances?.forEach((instance) => instance.resetRuntime()) - 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 index a0e72df7fa..0a8e321ec3 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -1,4 +1,5 @@ import { LiteDebouncer } from '@tanstack/pacer-lite' +import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib' import type { StandardSchemaV1 } from './standardSchema.public' import type { BaseValidator, @@ -12,6 +13,11 @@ export type InternalValidatorDefinition = BaseValidator< export type ValidatorInstanceDebouncedFn = (...args: Array) => any +export type AnyInternalValidatorInstance< + TDebouncedFn extends ValidatorInstanceDebouncedFn = + ValidatorInstanceDebouncedFn, +> = InternalValidatorInstance + export interface InternalValidatorInstanceOptions< out TDefinition extends InternalValidatorDefinition, out TOwner, @@ -19,17 +25,32 @@ export interface InternalValidatorInstanceOptions< definition: TDefinition owner: TOwner scope: ValidatorScope + index?: number } /** Stable runtime instances correlated with validator definitions by slot. */ export type InternalValidatorInstances< TDefinition extends InternalValidatorDefinition, TOwner, -> = Array> | null + 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 @@ -41,11 +62,27 @@ export interface ReconcileValidatorInstancesOptions< */ previousDefinitions?: ReadonlyArray | null /** The owner's currently installed instances, if it has any. */ - instances: InternalValidatorInstances + 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 } /** @@ -59,12 +96,7 @@ export class InternalValidatorInstance< TSchemaOutput = unknown, TDebouncedFn extends ValidatorInstanceDebouncedFn = ValidatorInstanceDebouncedFn, -> { - /** The validation boundary that owns this installed validator occurrence. */ - readonly owner: TOwner - /** The form, group, or field scope in which the validator executes. */ - readonly scope: ValidatorScope - +> 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. */ @@ -72,19 +104,15 @@ export class InternalValidatorInstance< /** The lazily created debouncer for this validator's pending execution. */ debouncer: LiteDebouncer | null = null /** - * The most recently stored Standard Schema output. + * The Standard Schema output assigned by the latest form or group submit. * - * Consult `hasSchemaOutput` because `undefined` can itself be a stored output. + * 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 `schemaOutput` has been assigned, including to `undefined`. */ + /** Whether the current submit pipeline assigned `schemaOutput`. */ hasSchemaOutput = false - /** - * Targets currently receiving errors routed from this validator. - * - * The set is allocated on first use and returns to `null` when empty. - */ - errorTargets: Set | null = null /** * Resolved fields referenced by this validator's `watchFields` definition. * @@ -99,22 +127,15 @@ export class InternalValidatorInstance< * Assigning the same definition again still advances the revision. */ revision = 0 - /** - * Whether this instance has been permanently disposed. - * - * Mutation helpers become no-ops after disposal. - */ - disposed = false - /** Creates the runtime state for one installed validator occurrence. */ constructor({ definition, owner, scope, + index, }: InternalValidatorInstanceOptions) { + super({ owner, scope, index }) this.definition = definition - this.owner = owner - this.scope = scope } /** @@ -182,17 +203,21 @@ export class InternalValidatorInstance< } /** - * Stores the latest Standard Schema output and marks it as present. + * Stores a submit pipeline's Standard Schema output when its result has one. * - * An explicit `undefined` is still considered a stored output. The operation - * is ignored after disposal. + * 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 schemaOutput - The output produced by the validator's schema. + * @param result - The accepted result produced by the validator pipeline. */ - setSchemaOutput(schemaOutput: TSchemaOutput): void { - if (this.disposed) return + setSchemaOutput(result: { + schemaResult: TSchemaOutput | null + hasSchemaResult: boolean + }): void { + if (this.disposed || !result.hasSchemaResult) return - this.schemaOutput = schemaOutput + this.schemaOutput = result.schemaResult as TSchemaOutput this.hasSchemaOutput = true } @@ -203,33 +228,6 @@ export class InternalValidatorInstance< this._clearSchemaOutput() } - /** - * Records a target receiving errors from this validator. - * - * The backing set is allocated lazily. The operation is ignored after disposal. - * - * @param errorTarget - The target receiving routed validation errors. - */ - addErrorTarget(errorTarget: TErrorTarget): void { - if (this.disposed) return - - if (!this.errorTargets) { - this.errorTargets = new Set() - } - this.errorTargets.add(errorTarget) - } - - /** - * Stops tracking an error target. - * - * @param errorTarget - The target whose routed-error association is removed. - */ - deleteErrorTarget(errorTarget: TErrorTarget): void { - if (this.disposed) return - - this.errorTargets?.delete(errorTarget) - } - /** * Associates a configured watched-field name with its resolved field. * @@ -252,6 +250,9 @@ export class InternalValidatorInstance< if (this.disposed) return this.resolvedWatchFields?.delete(name) + if (this.resolvedWatchFields?.size === 0) { + this.resolvedWatchFields = null + } } /** Marks mount validation as completed for this occurrence. */ @@ -261,6 +262,13 @@ export class InternalValidatorInstance< 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. * @@ -280,28 +288,20 @@ export class InternalValidatorInstance< * preserved. The operation is ignored after disposal. */ resetRuntime(): void { - if (this.disposed) return + super.resetRuntime() + } + /** Releases validator-specific runtime state during reset or disposal. */ + protected override _resetRuntime(): void { this._cancelExecution() this._clearSchemaOutput() - this.errorTargets = null } - /** - * Permanently disposes this validator occurrence and its runtime resources. - * - * Disposal cancels execution, releases outputs and collections, clears the - * mount marker, and is idempotent. Mutation helpers subsequently become no-ops. - */ - dispose(): void { - if (this.disposed) return - - this._cancelExecution() - this._clearSchemaOutput() - this.errorTargets = null + /** Releases validator-only collections and mount state during disposal. */ + protected override _disposeRuntime(): void { + this._resetRuntime() this.resolvedWatchFields = null this.didRunOnMount = false - this.disposed = true } /** Cancels and releases execution resources without checking disposal state. */ @@ -330,16 +330,29 @@ export class InternalValidatorInstance< export function reconcileValidatorInstances< TDefinition extends InternalValidatorDefinition, TOwner, + TErrorTarget = unknown, + TWatchedField = unknown, + TSchemaOutput = unknown, >({ definitions, previousDefinitions, instances, owner, scope, + onBeforeDispose, }: ReconcileValidatorInstancesOptions< TDefinition, - TOwner ->): InternalValidatorInstances { + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput +>): InternalValidatorInstances< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput +> { if ( previousDefinitions !== undefined && (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) @@ -350,7 +363,9 @@ export function reconcileValidatorInstances< } if (!definitions || definitions.length === 0) { - instances?.forEach((instance) => instance.dispose()) + instances?.forEach((instance) => { + instance.dispose(onBeforeDispose) + }) return null } @@ -362,16 +377,21 @@ export function reconcileValidatorInstances< if (instance) { instance.updateDefinition(definition) } else { - nextInstances[index] = new InternalValidatorInstance({ - definition, - owner, - scope, - }) + nextInstances[index] = new InternalValidatorInstance< + TDefinition, + TOwner, + TErrorTarget, + TWatchedField, + TSchemaOutput + >({ definition, owner, scope, index }) } }) for (let index = definitions.length; index < nextInstances.length; index++) { - nextInstances[index]?.dispose() + const instance = nextInstances[index] + if (!instance) continue + + instance.dispose(onBeforeDispose) } nextInstances.length = definitions.length 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 0d8fcb9e82..8bdcd3addf 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -7,8 +7,9 @@ export * from './FieldGroup/FieldGroupApi.lib' export * from './utils.lib' export * from './types.lib' export * from './FieldApi/RootFieldApi.lib' -export * from './validation.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 9865435d04..229b544ec1 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -3,9 +3,10 @@ import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' import { parseStandardSchemaIssues } from './standardSchema.lib' import { cancelPipelineCache, createPipelineCache, evaluate } from './utils.lib' -import { runValidatorPipeline } from './validation.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,24 +126,25 @@ export async function validateServerValues< values: TFormData, ): Promise> { const pipeline = options.validators - const schemaOutputs: Array = Array.from( - { length: pipeline?.length ?? 0 }, - () => undefined, - ) + const validatorInstances = reconcileValidatorInstances({ + definitions: pipeline, + instances: null, + owner: options, + scope: 'form', + }) if (!pipeline || pipeline.length === 0) { return { success: true, values, - schemaOutputs: schemaOutputs as never, + schemaOutputs: [] as never, } } const pipelineResult = await runValidatorPipeline< ServerFormValidateResult >({ - pipeline, - cache: createPipelineCache(), + pipeline: validatorInstances ?? [], context: { scope: 'server', event: 'server', @@ -168,21 +164,33 @@ export async function validateServerValues< }) if (pipelineResult.thrownError !== null) { + validatorInstances?.forEach((instance) => instance.dispose()) 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, + })) + + validatorInstances?.forEach((instance) => instance.dispose()) if (pipelineResult.hasErrors) { return { success: false, serverState: { values, - validationResults: pipelineResult.results, + validationResults, submissionAttempts: 1, }, } diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index 3c4533d14b..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.lib' -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.lib.ts b/packages/form-core/src/validation.lib.ts deleted file mode 100644 index b19dc213b9..0000000000 --- a/packages/form-core/src/validation.lib.ts +++ /dev/null @@ -1,1294 +0,0 @@ -import { LiteDebouncer } from '@tanstack/pacer-lite' -import { createErrorMap } from './validation.public' -import { - isStandardSchema, - parseStandardSchema, - parseStandardSchemaIssues, -} from './standardSchema.lib' -import { - evaluate, - isNil, - isNotNil, - isPromiseLike, - normalizeToArray, -} from './utils.lib' -import type { PipelineCache } from './utils.lib' -import type { - FieldValidateResult, - FieldValidator, - FieldValidatorContext, - FormGroupValidateResult, - FormGroupValidator, - FormGroupValidatorContext, - FormValidateResult, - FormValidator, - FormValidatorContext, - ServerFormValidatorContext, - ValidationErrorInput, - ValidationErrorMap, - ValidationIssue, - ValidationPredicateContext, - ValidationTriggerOption, - Validator, -} from './validation.public' -import type { InternalFormApi } from './FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { AnyInternalFormGroupApi } from './FormGroupApi/FormGroupApi.lib' - -type FormValidateContext = { - scope: 'form' - event: Exclude['event'], 'server'> - signal: AbortSignal - formApi: InternalFormApi - triggerFieldApi?: AnyInternalFieldApi -} -type ServerPipelineValidateContext = { - scope: 'server' - event: 'server' - signal: AbortSignal - formApi: undefined -} -type FieldValidateContext = Omit< - FieldValidatorContext, - 'value' | 'parseIssues' -> & { scope: 'field' } -type FormGroupValidateContext = Omit< - FormGroupValidatorContext, - 'value' | 'parseIssues' | 'createErrorMap' -> & { scope: 'group' } -type FormInputContext = Omit -type ServerFormInputContext = Omit -type FieldInputContext = Omit -type FormGroupInputContext = Omit - -export type InputContext = - | FormInputContext - | ServerFormInputContext - | FieldInputContext - | FormGroupInputContext -export type ValidateContext = - | FormValidateContext - | ServerPipelineValidateContext - | FieldValidateContext - | FormGroupValidateContext -type ValidateResult = - FormValidateResult | FormGroupValidateResult | FieldValidateResult -type AnyPipelineValidator = - | FormValidator - | FormGroupValidator - | FieldValidator - | Validator -type AnyValidatorContext = - | FormValidatorContext - | ServerFormValidatorContext - | FormGroupValidatorContext - | FieldValidatorContext - -type MountValidationExecutionResult = { - result: TResult - schemaResult: any | null - hasSchemaResult: boolean -} - -function isServerContext(ctx: InputContext): ctx is ServerFormInputContext { - return ctx.event === 'server' -} - -function isFieldContext(ctx: InputContext): ctx is FieldInputContext { - return ctx.scope === 'field' -} - -function isGroupContext(ctx: InputContext): ctx is FormGroupInputContext { - return ctx.scope === 'group' -} - -function isServerValidateContext( - ctx: ValidateContext, -): ctx is ServerPipelineValidateContext { - return ctx.event === 'server' -} - -function isFieldValidateContext( - ctx: ValidateContext, -): ctx is FieldValidateContext { - return ctx.scope === 'field' -} - -function isServerTrigger( - trigger: ValidationTriggerOption | 'server', -): boolean { - return trigger === 'server' -} - -function hasServerTrigger(validator: AnyPipelineValidator): boolean { - return validator.triggers.some(isServerTrigger) -} - -function getPredicateContext( - context: Exclude, -): ValidationPredicateContext { - if (isFieldContext(context)) { - return { - scope: 'field', - formApi: context.formApi, - fieldApi: context.fieldApi, - value: context.fieldApi.value, - } - } - - if (isGroupContext(context)) { - return { - scope: 'group', - formApi: context.formApi, - fieldApi: context.triggerFieldApi, - groupApi: context.groupApi, - value: context.groupApi.value, - } - } - - return { - scope: 'form', - formApi: context.formApi, - fieldApi: context.triggerFieldApi, - value: context.formApi.state.values, - } -} - -function parseFieldIssues( - issues: Parameters['parseIssues']>[0], -) { - return parseStandardSchemaIssues(issues, undefined, 'field') -} - -const ABORTED_CALL = Symbol('ABORTED_CALL') -const THROWN_ERROR = Symbol('THROWN_ERROR') - -type AbortedCall = typeof ABORTED_CALL -type ThrownError = { [THROWN_ERROR]: true; error: unknown } - -export function normalizeValidationError( - value: ValidationErrorInput | null | undefined, -): Array { - return normalizeToArray(value).map((error) => - typeof error === 'string' ? { message: error } : error, - ) -} - -export interface ParsedValidationResult { - self: Array | null - subfields: Record> | null -} - -/** - * @private - * Check whether a validation result is an error map. - */ -export function isValidationErrorMap( - value: unknown, -): value is ValidationErrorMap { - if (typeof value !== 'object') return false - if (value === null) return false - if (Array.isArray(value)) return false - if ('message' in value) return false - if (!('fields' in value)) return false - - const fields = value.fields - if (typeof fields !== 'object') return false - if (fields === null) return false - if (Array.isArray(fields)) return false - return true -} - -/** - * @private - * Normalize a validation result into errors owned by the validation boundary - * and errors routed to its subfields. - */ -export function parseValidationResult( - value: ValidateResult, -): ParsedValidationResult { - if (isNil(value) || value === false) { - return { self: null, subfields: null } - } - - if (isValidationErrorMap(value)) { - const normalizedSelf = normalizeValidationError(value.form) - const subfields: Record> = {} - - for (const [fieldName, fieldError] of Object.entries(value.fields)) { - const normalizedFieldError = normalizeValidationError(fieldError) - - if (normalizedFieldError.length > 0) { - subfields[fieldName] = normalizedFieldError - } - } - - return { - self: normalizedSelf.length > 0 ? normalizedSelf : null, - subfields, - } - } - - const normalizedSelf = normalizeValidationError(value) - - return { - self: normalizedSelf.length > 0 ? normalizedSelf : null, - subfields: null, - } -} - -/** - * @private - * Check if a validation result contains an error that would be stored. - */ -export function isErrorResult( - value: T, -): value is Exclude { - const { self, subfields } = parseValidationResult(value) - - return ( - self !== null || (subfields !== null && Object.keys(subfields).length > 0) - ) -} - -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 function hasIndexedErrors( - errors: Array>, -): boolean { - return errors.some((validatorErrors) => validatorErrors.length > 0) -} - -export function setIndexedError( - errors: Array>, - errorSourceEvents: Array, - index: number, - error: Array, - sourceEvent: string, -): { - errors: Array> - errorSourceEvents: Array -} | null { - const nextSourceEvent = error.length > 0 ? sourceEvent : null - const prevError = errors[index] ?? [] - - if ( - evaluate(prevError, error) && - errorSourceEvents[index] === nextSourceEvent - ) { - 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, - } -} - -export function clearIndexedErrorsFromSource( - errors: Array>, - errorSourceEvents: Array, - indexes: Array, - sourceEvent: string, -): { - errors: Array> - errorSourceEvents: Array -} | null { - let nextErrors: Array> | null = null - let nextErrorSourceEvents: Array | null = null - - for (const index of indexes) { - if ( - hasIndexedErrorFromSource(errors, errorSourceEvents, index, sourceEvent) - ) { - nextErrors ??= errors.slice() - nextErrorSourceEvents ??= errorSourceEvents.slice() - nextErrors[index] = [] - nextErrorSourceEvents[index] = null - } - } - - if (!nextErrors || !nextErrorSourceEvents) return null - - return { - errors: nextErrors, - errorSourceEvents: nextErrorSourceEvents, - } -} - -export function reconcileRoutedFieldErrors( - validatorIndex: number, - fieldErrors: Iterable]>, - oldFieldRefs: Set | undefined, - setFieldError: ( - field: AnyInternalFieldApi, - validatorIndex: number, - errors: Array, - ) => void, - clearFieldError: (field: AnyInternalFieldApi, validatorIndex: number) => void, -): { - fieldRefs: Set - affectedFields: Set - didFieldRefsChange: boolean -} { - const staleFieldRefs = oldFieldRefs ? new Set(oldFieldRefs) : undefined - const affectedFields = new Set() - const newFieldRefs = new Set() - - for (const [field, fieldError] of fieldErrors) { - setFieldError(field, validatorIndex, fieldError) - newFieldRefs.add(field) - affectedFields.add(field) - staleFieldRefs?.delete(field) - } - - if (staleFieldRefs) { - for (const field of staleFieldRefs) { - clearFieldError(field, validatorIndex) - affectedFields.add(field) - } - } - - return { - fieldRefs: newFieldRefs, - affectedFields, - didFieldRefsChange: - newFieldRefs.size > 0 || - (oldFieldRefs !== undefined && oldFieldRefs.size > 0), - } -} - -export interface PipelineResult { - validatorIndex: number - result: T - schemaResult: any | null - hasSchemaResult?: boolean -} - -interface ValidatorExecutionResult { - result: TResult - schemaResult: any | null - hasSchemaResult: boolean -} - -interface PendingDebouncedCall { - context: ValidateContext - resolve: ( - value: ValidatorExecutionResult | AbortedCall | ThrownError, - ) => void - reject: (error: unknown) => void -} - -export type ValidationDebouncer = LiteDebouncer< - (call: PendingDebouncedCall) => void -> - -interface PendingPipelineResult { - validatorIndex: number - result: T -} - -function getEnabledState( - booleanOrFn: boolean | ((context: any) => boolean), - context: InputContext, -): boolean { - if (typeof booleanOrFn === 'boolean') return booleanOrFn - if (isServerContext(context)) return false - - return booleanOrFn(getPredicateContext(context)) -} - -function getDebounceMs( - numberOrFn: number | ((context: any) => number), - context: InputContext, -): number { - if (typeof numberOrFn === 'number') return numberOrFn - if (isServerContext(context)) return 0 - - return numberOrFn(getPredicateContext(context)) -} - -export function isValidationTriggerEnabled( - trigger: ValidationTriggerOption | 'server', - context: InputContext, -): boolean { - if (typeof trigger === 'string') { - return trigger === context.event - } - - if (trigger.trigger !== context.event) { - return false - } - - const { when: enabled = true } = trigger - - return getEnabledState(enabled, context) -} - -function shouldRunValidator( - validator: AnyPipelineValidator, - context: InputContext, -): boolean { - if (isServerContext(context)) { - return hasServerTrigger(validator) - } - - const { runOnSubmit = true } = validator - - if (context.event === 'submit') { - return getEnabledState(runOnSubmit, context) - } - - return validator.triggers.some((signal) => - isValidationTriggerEnabled(signal, context), - ) -} - -async function executeValidator( - validator: AnyPipelineValidator, - context: AnyValidatorContext, - scope: 'field' | 'form', -): Promise> { - if (isStandardSchema(validator.run)) { - return parseStandardSchema(validator.run, context.value, scope) as never - } - - return { - result: (await validator.run(context)) as TResult, - schemaResult: null, - hasSchemaResult: false, - } -} - -interface ValidatorPipelineArgs { - context: InputContext - cache: PipelineCache - pipeline: ReadonlyArray - hasFailedBefore: boolean - getContext: (inputContext: ValidateContext) => AnyValidatorContext - scope: 'field' | 'form' - validatorIndecesToRun?: Array | null - onResult?: (result: PipelineResult) => void -} - -interface RunMaybeDebouncedValidatorArgs< - in out TResult extends ValidateResult, -> { - validator: AnyPipelineValidator - 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) - } -} - -function createAbortPromise(signal: AbortSignal): { - promise: Promise - cleanup: () => void -} { - let onAbort = () => {} - - const promise = new Promise((resolve) => { - if (signal.aborted) { - resolve(ABORTED_CALL) - return - } - - onAbort = () => { - signal.removeEventListener('abort', onAbort) - resolve(ABORTED_CALL) - } - - signal.addEventListener('abort', onAbort) - }) - - return { - promise, - cleanup: () => { - signal.removeEventListener('abort', onAbort) - }, - } -} - -async function executeWithAbort( - context: ValidateContext, - onExecute: ( - inputContext: ValidateContext, - ) => Promise>, -): Promise | AbortedCall> { - if (context.signal.aborted) { - return ABORTED_CALL - } - - const { promise: abortPromise, cleanup } = createAbortPromise(context.signal) - - try { - return await Promise.race([ - Promise.resolve(onExecute(context)), - abortPromise, - ]) - } finally { - cleanup() - } -} - -function getValidatorDebounceMs( - validator: AnyPipelineValidator, - context: InputContext, -): number { - if (context.event === 'submit' || context.event === 'server') return 0 - - const { triggerDebounceMs = 0 } = validator - - 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() -} - -function createValidatorAbortContext( - cache: PipelineCache, - cacheKey: number, - opts?: { cancelDebouncer?: boolean }, -): { - abortController: AbortController - signal: AbortSignal - cleanup: () => void -} { - abortPreviousValidatorRun(cache, cacheKey) - - if (opts?.cancelDebouncer) { - cache.validatorDebouncers.get(cacheKey)?.cancel() - } - - const abortController = new AbortController() - const signal = abortController.signal - - cache.validatorAbortControllers.set(cacheKey, abortController) - - return { - abortController, - signal, - cleanup: () => { - clearAbortController(cache, cacheKey, 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 -} - -function runMaybeDebouncedValidator({ - validator, - context, - validatorIndex, - cache, - onExecute, -}: RunMaybeDebouncedValidatorArgs): Promise< - ValidatorExecutionResult | AbortedCall | ThrownError -> { - const cacheKey = validatorIndex - const debounceMs = getValidatorDebounceMs(validator, context) - - const { signal, cleanup } = createValidatorAbortContext(cache, cacheKey) - - const validationContext: ValidateContext = { - ...context, - signal, - } - - return new Promise< - ValidatorExecutionResult | AbortedCall | ThrownError - >((resolve) => { - let settled = false - - const settle = ( - value: ValidatorExecutionResult | AbortedCall | ThrownError, - ) => { - if (settled) return - - settled = true - cleanupAbortListener() - cleanup() - resolve(value) - } - - const fail = (error: unknown) => { - if (settled) return - - console.error('Validator threw an error:', error) - settle({ [THROWN_ERROR]: true, error }) - } - - const onAbort = () => { - cache.validatorDebouncers.get(cacheKey)?.cancel() - settle(ABORTED_CALL) - } - - const cleanupAbortListener = () => { - signal.removeEventListener('abort', onAbort) - } - - signal.addEventListener('abort', onAbort, { once: true }) - - const run = (ctx: ValidateContext) => { - executeWithAbort(ctx, onExecute).then(settle, fail) - } - - if (debounceMs <= 0) { - cache.validatorDebouncers.get(cacheKey)?.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, - ) - - debouncer.maybeExecute({ - context: validationContext, - resolve: settle, - // This should not be called anymore since we handle errors in the - // debouncer callback. - reject: () => {}, - }) - }) -} - -type PendingPromises = Array< - Promise< - PendingPipelineResult< - ValidatorExecutionResult | AbortedCall | ThrownError - > - > -> - -async function flushPendingResults( - pending: PendingPromises, - results: Array>, - onResult?: (result: PipelineResult) => void, -): Promise<{ hasErrors: boolean; thrownError: unknown | null }> { - let hasErrors = false - let thrownError: unknown | null = null - - await Promise.all( - pending.map(async (promise) => { - const result = await promise - - const executionResult = result.result - - if (executionResult === ABORTED_CALL) { - return - } - - // Check if this is a thrown error from a validator - if ( - isNotNil(executionResult) && - typeof executionResult === 'object' && - THROWN_ERROR in executionResult - ) { - thrownError = executionResult.error - return - } - - if (isErrorResult(executionResult.result)) { - hasErrors = true - } - - const publicResult: PipelineResult = { - validatorIndex: result.validatorIndex, - result: executionResult.result, - schemaResult: executionResult.schemaResult, - hasSchemaResult: executionResult.hasSchemaResult, - } - - results[result.validatorIndex] = publicResult - onResult?.(publicResult) - }), - ) - - return { hasErrors, thrownError } -} - -export async function runValidatorPipeline({ - pipeline, - context, - cache, - hasFailedBefore = false, - getContext, - onResult, - scope, - validatorIndecesToRun = null, -}: ValidatorPipelineArgs): Promise<{ - results: Array> - hasErrors: boolean - thrownError: unknown | null -}> { - let pending: PendingPromises = [] - const results: Array> = [] - - let hasErrors = hasFailedBefore - let thrownError: unknown | null = null - - const flush = async (): Promise => { - const { hasErrors: didError, thrownError: flushedThrownError } = - await flushPendingResults(pending, results, onResult) - - pending = [] - hasErrors ||= didError - if (flushedThrownError !== null) { - thrownError = flushedThrownError - } - } - - for (let i = 0; i < pipeline.length; i++) { - const validator = pipeline[i]! - - if (validatorIndecesToRun && !validatorIndecesToRun.includes(i)) { - continue - } - - if (!shouldRunValidator(validator, context)) { - continue - } - - if (validator.bailIfInvalid) { - await flush() - - if (hasErrors || thrownError !== null) { - break - } - } - - const promise = runMaybeDebouncedValidator({ - validator, - context, - validatorIndex: i, - cache, - onExecute: (ctx) => { - return executeValidator(validator, getContext(ctx), scope) - }, - }).then< - PendingPipelineResult< - ValidatorExecutionResult | AbortedCall | ThrownError - > - >((result) => ({ - validatorIndex: i, - result, - })) - - pending.push(promise) - } - - await flush() - - return { - // Shouldn't happen, but in case we have sparse arrays - results: results.filter(Boolean), - hasErrors, - thrownError, - } -} - -interface FormValidatorPipelineArgs { - pipeline: ReadonlyArray> - context: FormInputContext - /** - * @private - * Whether previous pipelines have reported an error or not. - */ - hasFailedBefore: boolean - onResult?: (result: PipelineResult>) => void -} - -export interface FormValidatorPipelineResult { - results: Array>> - hasErrors: boolean - thrownError: unknown | null -} - -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') - } - - if (!isFieldValidateContext(ctx)) { - return { - event: ctx.event, - 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, - 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', - }) -} - -interface FormMountValidatorPipelineArgs { - pipeline: ReadonlyArray> - formApi: InternalFormApi - onResult?: (result: PipelineResult>) => void -} - -export interface FormMountValidatorPipelineResult { - didRun: boolean - asyncPromise: Promise | null -} - -interface MountValidatorPipelineArgs { - pipeline: ReadonlyArray - cache: PipelineCache - getContext: (signal: AbortSignal) => AnyValidatorContext - scope: 'field' | 'form' - onResult?: (result: PipelineResult) => void -} - -function createEmptyMountValidationResult< - TResult extends ValidateResult, ->(): MountValidationExecutionResult { - return { - result: null as TResult, - schemaResult: null, - hasSchemaResult: false, - } -} - -function processMountValidationExecutionResult( - validatorIndex: number, - executionResult: MountValidationExecutionResult, - onResult?: (result: PipelineResult) => void, -): boolean { - const result: PipelineResult = { - validatorIndex, - result: executionResult.result, - schemaResult: executionResult.schemaResult, - hasSchemaResult: executionResult.hasSchemaResult, - } - - onResult?.(result) - - return isErrorResult(executionResult.result) -} - -function executeMountValidator( - cache: PipelineCache, - getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', - validator: AnyPipelineValidator, - validatorIndex: number, -): - | MountValidationExecutionResult - | PromiseLike> { - const { signal, cleanup } = createValidatorAbortContext( - cache, - validatorIndex, - { cancelDebouncer: true }, - ) - - const context = getContext(signal) - - try { - if (isStandardSchema(validator.run)) { - return parseStandardSchema(validator.run, context.value, scope) - .then((result) => { - if (signal.aborted) { - return createEmptyMountValidationResult() - } - - return result - }) - .finally(cleanup) as unknown as PromiseLike< - MountValidationExecutionResult - > - } - - const result = validator.run(context) - - if (isPromiseLike(result)) { - return Promise.resolve(result) - .then((asyncResult): MountValidationExecutionResult => { - if (signal.aborted) { - return createEmptyMountValidationResult() - } - - return { - result: asyncResult as TResult, - schemaResult: null, - hasSchemaResult: false, - } - }) - .finally(cleanup) - } - - cleanup() - return { - result: result as TResult, - schemaResult: null, - hasSchemaResult: false, - } - } catch (error) { - cleanup() - console.error(error) - return createEmptyMountValidationResult() - } -} - -async function continueMountValidationFromAsyncResult< - TResult extends ValidateResult, ->( - pipeline: ReadonlyArray, - cache: PipelineCache, - getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', - startIndex: number, - firstResult: PromiseLike>, - hasFailedBefore: boolean, - onResult?: (result: PipelineResult) => void, -): Promise { - let hasFailed = hasFailedBefore - - const firstExecutionResult = await firstResult - if ( - processMountValidationExecutionResult( - startIndex, - firstExecutionResult, - onResult, - ) - ) { - hasFailed = true - } - - for (let i = startIndex + 1; i < pipeline.length; i++) { - const validator = pipeline[i]! - if (validator.runOnMount !== true) continue - - if (validator.bailIfInvalid && hasFailed) break - - const result = executeMountValidator( - cache, - getContext, - scope, - validator, - i, - ) - const executionResult = isPromiseLike(result) ? await result : result - - if (processMountValidationExecutionResult(i, executionResult, onResult)) { - hasFailed = true - } - } -} - -function runMountValidatorPipeline({ - pipeline, - cache, - getContext, - scope, - onResult, -}: MountValidatorPipelineArgs): FormMountValidatorPipelineResult { - if (pipeline.length === 0) - return { - didRun: false, - asyncPromise: null, - } - - if (!pipeline.some((validator) => validator.runOnMount === true)) - return { - didRun: false, - asyncPromise: null, - } - - let hasFailed = false - - for (let i = 0; i < pipeline.length; i++) { - const validator = pipeline[i]! - if (validator.runOnMount !== true) continue - - if (validator.bailIfInvalid && hasFailed) { - return { - didRun: true, - asyncPromise: null, - } - } - - const result = executeMountValidator( - cache, - getContext, - scope, - validator, - i, - ) - - if (isPromiseLike(result)) { - return { - didRun: true, - asyncPromise: continueMountValidationFromAsyncResult( - pipeline, - cache, - getContext, - scope, - i, - result, - hasFailed, - onResult, - ), - } - } - - if (processMountValidationExecutionResult(i, result, onResult)) { - hasFailed = true - } - } - - return { - didRun: true, - asyncPromise: null, - } -} - -export function runFormMountValidatorPipeline({ - pipeline, - formApi, - onResult, -}: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { - return runMountValidatorPipeline>({ - pipeline, - cache: formApi._pipelineCache, - getContext: (signal) => ({ - event: 'mount' as never, - signal, - formApi, - value: formApi.state.values, - createErrorMap, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, formApi.state.values, 'form'), - }), - scope: 'form', - onResult, - }) -} - -interface FieldValidatorPipelineArgs { - pipeline: Array> - context: FieldInputContext - onResult?: (result: PipelineResult) => void - /** - * @private - * When an incoming watched field notifies, we should only run validators - * that are actually interested in it. - */ - validatorIndecesToRun?: Array | null -} - -export interface FieldValidatorPipelineResult { - results: Array> - hasErrors: boolean - thrownError: unknown | null -} - -export function runFieldValidatorPipeline({ - pipeline, - context, - onResult, - validatorIndecesToRun = null, -}: FieldValidatorPipelineArgs): Promise { - const fieldApi = context.fieldApi as AnyInternalFieldApi - - if (fieldApi._isKilled) - return Promise.resolve({ - results: [], - hasErrors: false, - 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) => { - if (isServerValidateContext(ctx)) { - throw new Error('Server validation cannot run through field pipeline') - } - - return { - event: context.event, - formApi: context.formApi, - signal: ctx.signal, - fieldApi: context.fieldApi, - value: context.fieldApi.value, - parseIssues: parseFieldIssues, - } - }, - scope: 'field', - validatorIndecesToRun, - }) -} - -interface FieldMountValidatorPipelineArgs { - pipeline: ReadonlyArray> - fieldApi: AnyInternalFieldApi - onResult?: (result: PipelineResult) => void -} - -export function runFieldMountValidatorPipeline({ - pipeline, - fieldApi, - onResult, -}: FieldMountValidatorPipelineArgs): FormMountValidatorPipelineResult { - return runMountValidatorPipeline({ - pipeline, - cache: fieldApi._getOrCreatePipelineCache(), - getContext: (signal) => ({ - event: 'mount' as never, - signal, - formApi: fieldApi.form as never, - fieldApi: fieldApi as never, - value: fieldApi.value, - parseIssues: parseFieldIssues, - }), - scope: 'field', - onResult, - }) -} - -// ===== GROUP MOUNT VALIDATION ===== - -interface GroupMountValidatorPipelineArgs { - pipeline: ReadonlyArray> - groupApi: AnyInternalFormGroupApi - onResult?: (result: PipelineResult>) => void -} - -export function runGroupMountValidatorPipeline({ - pipeline, - groupApi, - onResult, -}: GroupMountValidatorPipelineArgs): FormMountValidatorPipelineResult { - return runMountValidatorPipeline>({ - pipeline, - cache: groupApi._pipelineCache, - getContext: (signal) => ({ - event: 'mount' as never, - signal, - formApi: groupApi.form as never, - groupApi: groupApi as never, - triggerFieldApi: undefined, - value: groupApi.value, - createErrorMap, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, groupApi.value, 'form'), - }), - scope: 'form', - onResult, - }) -} diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts new file mode 100644 index 0000000000..4606bab024 --- /dev/null +++ b/packages/form-core/src/validation/errors.lib.ts @@ -0,0 +1,257 @@ +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, + FormGroupValidateResult, + FormValidateResult, + ValidationErrorInput, + ValidationErrorMap, + ValidationIssue, +} from '../validation.public' + +type ValidateResult = + FormValidateResult | FormGroupValidateResult | FieldValidateResult + +export function normalizeValidationError( + value: ValidationErrorInput | null | undefined, +): Array { + return normalizeToArray(value).map((error) => + typeof error === 'string' ? { message: error } : error, + ) +} + +export interface ParsedValidationResult { + self: Array | null + subfields: Record> | null +} + +/** + * 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, +): value is ValidationErrorMap { + if (typeof value !== 'object') return false + if (value === null) return false + if (Array.isArray(value)) return false + if ('message' in value) return false + if (!('fields' in value)) return false + + const fields = value.fields + if (typeof fields !== 'object') return false + if (fields === null) return false + if (Array.isArray(fields)) return false + return true +} + +/** + * 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, +): ParsedValidationResult { + if (isNil(value) || value === false) { + return { self: null, subfields: null } + } + + if (isValidationErrorMap(value)) { + const normalizedSelf = normalizeValidationError(value.form) + const subfields: Record> = {} + + for (const [fieldName, fieldError] of Object.entries(value.fields)) { + const normalizedFieldError = normalizeValidationError(fieldError) + + if (normalizedFieldError.length > 0) { + subfields[fieldName] = normalizedFieldError + } + } + + return { + self: normalizedSelf.length > 0 ? normalizedSelf : null, + subfields, + } + } + + const normalizedSelf = normalizeValidationError(value) + + return { + self: normalizedSelf.length > 0 ? normalizedSelf : null, + subfields: null, + } +} + +/** + * Checks whether a validation result contains an error that would be stored. + */ +export function isErrorResult( + value: T, +): value is Exclude { + const { self, subfields } = parseValidationResult(value) + + return ( + self !== null || (subfields !== null && Object.keys(subfields).length > 0) + ) +} + +export interface ValidationSourceErrorState { + errors: Array + sourceEvent: string +} + +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 errorMap?.get(validationSource)?.sourceEvent === sourceEvent +} + +/** + * 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, +): { errorMap: ValidationSourceErrorMap | null } | null { + const previous = errorMap?.get(validationSource) + if ( + previous && + evaluate(previous.errors, errors) && + previous.sourceEvent === sourceEvent + ) { + return null + } + if (!previous && errors.length === 0) return null + + 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 } +} + +/** + * 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, +): { errorMap: ValidationSourceErrorMap | null } | null { + if (!errorMap) return null + + let next: ValidationSourceErrorMap | null = null + for (const validationSource of validationSources) { + if ( + !hasValidationSourceErrorFromEvent( + errorMap, + validationSource, + sourceEvent, + ) + ) { + continue + } + + if (!next) { + next = new Map(errorMap) + } + next.delete(validationSource) + } + + if (!next) return null + return { errorMap: next.size > 0 ? next : null } +} + +/** + * 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( + validationSource: AnyInternalValidationSourceInstance, + fieldErrors: Iterable]>, + oldFieldRefs: Set | undefined, + setFieldError: ( + field: AnyInternalFieldApi, + validationSource: AnyInternalValidationSourceInstance, + errors: Array, + ) => void, + clearFieldError: ( + field: AnyInternalFieldApi, + validationSource: AnyInternalValidationSourceInstance, + ) => void, +): { + fieldRefs: Set + affectedFields: Set + didFieldRefsChange: boolean +} { + const staleFieldRefs = oldFieldRefs ? new Set(oldFieldRefs) : undefined + const affectedFields = new Set() + const newFieldRefs = new Set() + + for (const [field, fieldError] of fieldErrors) { + setFieldError(field, validationSource, fieldError) + newFieldRefs.add(field) + affectedFields.add(field) + staleFieldRefs?.delete(field) + } + + if (staleFieldRefs) { + for (const field of staleFieldRefs) { + clearFieldError(field, validationSource) + affectedFields.add(field) + } + } + + const didFieldRefsChange = + newFieldRefs.size > 0 || + (oldFieldRefs !== undefined && oldFieldRefs.size > 0) + + return { + fieldRefs: newFieldRefs, + affectedFields, + didFieldRefsChange, + } +} diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts new file mode 100644 index 0000000000..f272089a7b --- /dev/null +++ b/packages/form-core/src/validation/execution.lib.ts @@ -0,0 +1,463 @@ +import { + isStandardSchema, + parseStandardSchema, + parseStandardSchemaIssues, +} from '../standardSchema.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, + FieldValidatorContext, + FormGroupValidateResult, + FormGroupValidator, + FormGroupValidatorContext, + FormValidateResult, + FormValidator, + FormValidatorContext, + ServerFormValidatorContext, + ValidationPredicateContext, + ValidationTriggerOption, + Validator, +} from '../validation.public' + +type FormValidateContext = { + scope: 'form' + event: Exclude['event'], 'server'> + signal: AbortSignal + formApi: InternalFormApi + triggerFieldApi?: AnyInternalFieldApi +} +type ServerPipelineValidateContext = { + scope: 'server' + event: 'server' + signal: AbortSignal + formApi: undefined +} +type FieldValidateContext = Omit< + FieldValidatorContext, + 'value' | 'parseIssues' +> & { scope: 'field' } +type FormGroupValidateContext = Omit< + FormGroupValidatorContext, + 'value' | 'parseIssues' | 'createErrorMap' +> & { scope: 'group' } +export type FormInputContext = Omit +type ServerFormInputContext = Omit +export type FieldInputContext = Omit +type FormGroupInputContext = Omit + +export type InputContext = + | FormInputContext + | ServerFormInputContext + | FieldInputContext + | FormGroupInputContext +export type ValidateContext = + | FormValidateContext + | ServerPipelineValidateContext + | FieldValidateContext + | FormGroupValidateContext +export type ValidateResult = + FormValidateResult | FormGroupValidateResult | FieldValidateResult +type AnyPipelineValidator = + | FormValidator + | FormGroupValidator + | FieldValidator + | Validator +export type AnyValidatorContext = + | FormValidatorContext + | ServerFormValidatorContext + | FormGroupValidatorContext + | FieldValidatorContext + +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 { + if (isFieldContext(context)) { + return { + scope: 'field', + formApi: context.formApi, + fieldApi: context.fieldApi, + value: context.fieldApi.value, + } + } + + if (isGroupContext(context)) { + return { + scope: 'group', + formApi: context.formApi, + fieldApi: context.triggerFieldApi, + groupApi: context.groupApi, + value: context.groupApi.value, + } + } + + return { + scope: 'form', + formApi: context.formApi, + fieldApi: context.triggerFieldApi, + value: context.formApi.state.values, + } +} + +/** Parses Standard Schema issues as errors owned directly by a field. */ +export function parseFieldIssues( + issues: Parameters['parseIssues']>[0], +) { + return parseStandardSchemaIssues(issues, undefined, 'field') +} + +export const ABORTED_CALL = Symbol('ABORTED_CALL') +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 + hasSchemaResult: boolean +} + +interface PendingDebouncedCall { + context: ValidateContext + resolve: ( + value: ValidatorExecutionResult | AbortedCall | ThrownError, + ) => void + reject: (error: unknown) => void +} + +type PipelineValidatorInstance = + AnyInternalValidatorInstance<(call: PendingDebouncedCall) => void> + +function getEnabledState( + booleanOrFn: boolean | ((context: any) => boolean), + context: InputContext, +): boolean { + if (typeof booleanOrFn === 'boolean') return booleanOrFn + if (isServerContext(context)) return false + + return booleanOrFn(getPredicateContext(context)) +} + +/** Resolves a static delay or evaluates its callback outside server context. */ +function getDebounceMs( + numberOrFn: number | ((context: any) => number), + context: InputContext, +): number { + if (typeof numberOrFn === 'number') return numberOrFn + if (isServerContext(context)) return 0 + + 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, +): boolean { + if (typeof trigger === 'string') { + return trigger === context.event + } + + if (trigger.trigger !== context.event) { + return false + } + + const { when: enabled = true } = trigger + + return getEnabledState(enabled, context) +} + +/** Selects a validator using server, submit, or configured event semantics. */ +export function shouldRunValidator( + validator: AnyPipelineValidator, + context: InputContext, +): boolean { + if (isServerContext(context)) { + return hasServerTrigger(validator) + } + + const { runOnSubmit = true } = validator + + if (context.event === 'submit') { + return getEnabledState(runOnSubmit, context) + } + + return validator.triggers.some((signal) => + isValidationTriggerEnabled(signal, context), + ) +} + +/** + * 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, + scope: 'field' | 'form', +): Promise> { + if (isStandardSchema(validator.run)) { + return parseStandardSchema(validator.run, context.value, scope) as never + } + + return { + result: (await validator.run(context)) as TResult, + schemaResult: null, + hasSchemaResult: false, + } +} + +interface RunMaybeDebouncedValidatorArgs< + in out TResult extends ValidateResult, +> { + validatorInstance: PipelineValidatorInstance + context: InputContext + onExecute: ( + inputContext: ValidateContext, + ) => Promise> +} + +/** + * 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 +} { + let onAbort = () => {} + + const promise = new Promise((resolve) => { + if (signal.aborted) { + resolve(ABORTED_CALL) + return + } + + onAbort = () => { + signal.removeEventListener('abort', onAbort) + resolve(ABORTED_CALL) + } + + signal.addEventListener('abort', onAbort) + }) + + return { + promise, + cleanup: () => { + signal.removeEventListener('abort', onAbort) + }, + } +} + +/** + * 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: ( + inputContext: ValidateContext, + ) => Promise>, +): Promise | AbortedCall> { + if (context.signal.aborted) { + return ABORTED_CALL + } + + const { promise: abortPromise, cleanup } = createAbortPromise(context.signal) + + try { + return await Promise.race([ + Promise.resolve(onExecute(context)), + abortPromise, + ]) + } finally { + cleanup() + } +} + +/** Resolves debounce duration, forcing immediate submit and server execution. */ +function getValidatorDebounceMs( + validator: AnyPipelineValidator, + context: InputContext, +): number { + if (context.event === 'submit' || context.event === 'server') return 0 + + const { triggerDebounceMs = 0 } = validator + + return getDebounceMs(triggerDebounceMs, context) +} + +/** + * 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 +} { + if (opts?.cancelDebouncer) { + validatorInstance.debouncer?.cancel() + } + + const abortController = new AbortController() + const signal = abortController.signal + + validatorInstance.setAbortController(abortController) + + return { + abortController, + signal, + cleanup: () => { + validatorInstance.clearAbortController(abortController) + }, + } +} + +/** + * 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({ + validatorInstance, + context, + onExecute, +}: RunMaybeDebouncedValidatorArgs): Promise< + ValidatorExecutionResult | AbortedCall | ThrownError +> { + const validator = validatorInstance.definition + const debounceMs = getValidatorDebounceMs(validator, context) + + const { signal, cleanup } = createValidatorAbortContext(validatorInstance) + + const validationContext: ValidateContext = { + ...context, + signal, + } + + return new Promise< + ValidatorExecutionResult | AbortedCall | ThrownError + >((resolve) => { + let settled = false + + const settle = ( + value: ValidatorExecutionResult | AbortedCall | ThrownError, + ) => { + if (settled) return + + settled = true + cleanupAbortListener() + cleanup() + resolve(value) + } + + const fail = (error: unknown) => { + if (settled) return + + console.error('Validator threw an error:', error) + settle({ [THROWN_ERROR]: true, error }) + } + + const onAbort = () => { + validatorInstance.debouncer?.cancel() + settle(ABORTED_CALL) + } + + const cleanupAbortListener = () => { + signal.removeEventListener('abort', onAbort) + } + + signal.addEventListener('abort', onAbort, { once: true }) + + const run = (ctx: ValidateContext) => { + executeWithAbort(ctx, onExecute).then(settle, fail) + } + + if (debounceMs <= 0) { + validatorInstance.debouncer?.cancel() + run(validationContext) + return + } + + 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) + + debouncer?.maybeExecute({ + context: validationContext, + resolve: settle, + // This should not be called anymore since we handle errors in the + // debouncer callback. + reject: () => {}, + }) + }) +} diff --git a/packages/form-core/src/validation/index.ts b/packages/form-core/src/validation/index.ts new file mode 100644 index 0000000000..f8c5e0b5a6 --- /dev/null +++ b/packages/form-core/src/validation/index.ts @@ -0,0 +1,34 @@ +export { + clearValidationSourceErrorsFromEvent, + getValidationSourceErrors, + hasValidationSourceErrorFromEvent, + isErrorResult, + isValidationErrorMap, + normalizeValidationError, + parseValidationResult, + reconcileRoutedFieldErrors, + setValidationSourceError, +} from './errors.lib' +export type { + ParsedValidationResult, + ValidationSourceErrorMap, + ValidationSourceErrorState, +} from './errors.lib' +export { isValidationTriggerEnabled } from './execution.lib' +export type { InputContext, ValidateContext } from './execution.lib' +export { + runFieldValidatorPipeline, + runFormValidatorPipeline, + runValidatorPipeline, +} from './pipeline.lib' +export type { + FieldValidatorPipelineResult, + FormValidatorPipelineResult, + PipelineResult, +} from './pipeline.lib' +export { + runFieldMountValidatorPipeline, + runFormMountValidatorPipeline, + runGroupMountValidatorPipeline, +} from './mount.lib' +export type { FormMountValidatorPipelineResult } from './mount.lib' diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts new file mode 100644 index 0000000000..ed56098bf8 --- /dev/null +++ b/packages/form-core/src/validation/mount.lib.ts @@ -0,0 +1,359 @@ +import { createErrorMap } from '../validation.public' +import { + isStandardSchema, + parseStandardSchema, + parseStandardSchemaIssues, +} from '../standardSchema.lib' +import { isPromiseLike } from '../utils.lib' +import { isErrorResult } from './errors.lib' +import { createValidatorAbortContext, parseFieldIssues } from './execution.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, + FormGroupValidateResult, + FormValidateResult, +} from '../validation.public' +import type { AnyValidatorContext, ValidateResult } from './execution.lib' +import type { PipelineResult } from './pipeline.lib' + +type MountValidationExecutionResult = { + result: TResult + schemaResult: any | null + hasSchemaResult: boolean +} + +interface FormMountValidatorPipelineArgs { + pipeline: ReadonlyArray + formApi: InternalFormApi + onResult?: (result: PipelineResult>) => void +} + +export interface FormMountValidatorPipelineResult { + didRun: boolean + asyncPromise: Promise | null +} + +interface MountValidatorPipelineArgs { + 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 { + return { + result: null as TResult, + schemaResult: null, + hasSchemaResult: false, + } +} + +/** + * 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( + validatorInstance: AnyInternalValidatorInstance, + executionResult: MountValidationExecutionResult, + onResult?: (result: PipelineResult) => void, +): boolean { + const result: PipelineResult = { + validatorInstance, + result: executionResult.result, + schemaResult: executionResult.schemaResult, + hasSchemaResult: executionResult.hasSchemaResult, + } + + onResult?.(result) + + 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( + getContext: MountValidatorPipelineArgs['getContext'], + scope: 'field' | 'form', + validatorInstance: AnyInternalValidatorInstance, +): + | MountValidationExecutionResult + | PromiseLike> { + const validator = validatorInstance.definition + const { signal, cleanup } = createValidatorAbortContext(validatorInstance, { + cancelDebouncer: true, + }) + + const context = getContext(signal) + + try { + if (isStandardSchema(validator.run)) { + return parseStandardSchema(validator.run, context.value, scope) + .then((result) => { + if (signal.aborted) { + return createEmptyMountValidationResult() + } + + return result + }) + .finally(cleanup) as unknown as PromiseLike< + MountValidationExecutionResult + > + } + + const result = validator.run(context) + + if (isPromiseLike(result)) { + return Promise.resolve(result) + .then((asyncResult): MountValidationExecutionResult => { + if (signal.aborted) { + return createEmptyMountValidationResult() + } + + return { + result: asyncResult as TResult, + schemaResult: null, + hasSchemaResult: false, + } + }) + .finally(cleanup) + } + + cleanup() + return { + result: result as TResult, + schemaResult: null, + hasSchemaResult: false, + } + } catch (error) { + cleanup() + console.error(error) + return createEmptyMountValidationResult() + } +} + +/** + * 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, + getContext: MountValidatorPipelineArgs['getContext'], + scope: 'field' | 'form', + startInstance: AnyInternalValidatorInstance, + firstResult: PromiseLike>, + hasFailedBefore: boolean, + onResult?: (result: PipelineResult) => void, +): Promise { + let hasFailed = hasFailedBefore + + const firstExecutionResult = await firstResult + if ( + processMountValidationExecutionResult( + startInstance, + firstExecutionResult, + onResult, + ) + ) { + hasFailed = true + } + + const startIndex = pipeline.indexOf(startInstance) + for (let i = startIndex + 1; i < pipeline.length; i++) { + const validatorInstance = pipeline[i]! + const validator = validatorInstance.definition + if (validator.runOnMount !== true) continue + + if (validator.bailIfInvalid && hasFailed) break + + const result = executeMountValidator( + getContext, + scope, + validatorInstance, + ) + const executionResult = isPromiseLike(result) ? await result : result + + 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, + getContext, + scope, + onResult, +}: MountValidatorPipelineArgs): FormMountValidatorPipelineResult { + if (pipeline.length === 0) + return { + didRun: false, + asyncPromise: null, + } + + if ( + !pipeline.some( + (validatorInstance) => validatorInstance.definition.runOnMount === true, + ) + ) + return { + didRun: false, + asyncPromise: null, + } + + let hasFailed = false + + for (const validatorInstance of pipeline) { + const validator = validatorInstance.definition + if (validator.runOnMount !== true) continue + + if (validator.bailIfInvalid && hasFailed) { + return { + didRun: true, + asyncPromise: null, + } + } + + const result = executeMountValidator( + getContext, + scope, + validatorInstance, + ) + + if (isPromiseLike(result)) { + return { + didRun: true, + asyncPromise: continueMountValidationFromAsyncResult( + pipeline, + getContext, + scope, + validatorInstance, + result, + hasFailed, + onResult, + ), + } + } + + if ( + processMountValidationExecutionResult(validatorInstance, result, onResult) + ) { + hasFailed = true + } + } + + return { + didRun: true, + asyncPromise: null, + } +} + +/** Runs mount validation with form-scoped values and routed issue parsing. */ +export function runFormMountValidatorPipeline({ + pipeline, + formApi, + onResult, +}: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { + return runMountValidatorPipeline>({ + pipeline, + getContext: (signal) => ({ + event: 'mount' as never, + signal, + formApi, + value: formApi.state.values, + createErrorMap, + parseIssues: (issues) => + parseStandardSchemaIssues(issues, formApi.state.values, 'form'), + }), + scope: 'form', + onResult, + }) +} + +interface FieldMountValidatorPipelineArgs { + 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, + onResult, +}: FieldMountValidatorPipelineArgs): FormMountValidatorPipelineResult { + return runMountValidatorPipeline({ + pipeline, + getContext: (signal) => ({ + event: 'mount' as never, + signal, + formApi: fieldApi.form as never, + fieldApi: fieldApi as never, + value: fieldApi.value, + parseIssues: parseFieldIssues, + }), + scope: 'field', + onResult, + }) +} + +// ===== GROUP MOUNT VALIDATION ===== + +interface GroupMountValidatorPipelineArgs { + pipeline: ReadonlyArray + groupApi: AnyInternalFormGroupApi + onResult?: (result: PipelineResult>) => void +} + +/** Runs mount validation with group-scoped values and routed issue parsing. */ +export function runGroupMountValidatorPipeline({ + pipeline, + groupApi, + onResult, +}: GroupMountValidatorPipelineArgs): FormMountValidatorPipelineResult { + return runMountValidatorPipeline>({ + pipeline, + getContext: (signal) => ({ + event: 'mount' as never, + signal, + formApi: groupApi.form as never, + groupApi: groupApi as never, + triggerFieldApi: undefined, + value: groupApi.value, + createErrorMap, + parseIssues: (issues) => + parseStandardSchemaIssues(issues, groupApi.value, 'form'), + }), + scope: 'form', + onResult, + }) +} diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts new file mode 100644 index 0000000000..44236399a7 --- /dev/null +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -0,0 +1,348 @@ +import { createErrorMap } from '../validation.public' +import { parseStandardSchemaIssues } from '../standardSchema.lib' +import { isNotNil } from '../utils.lib' +import { isErrorResult } from './errors.lib' +import { + ABORTED_CALL, + THROWN_ERROR, + executeValidator, + isFieldValidateContext, + isServerValidateContext, + parseFieldIssues, + runMaybeDebouncedValidator, + shouldRunValidator, +} from './execution.lib' +import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' +import type { + FieldValidateResult, + FormValidateResult, +} from '../validation.public' +import type { + AbortedCall, + AnyValidatorContext, + FieldInputContext, + FormInputContext, + InputContext, + ThrownError, + ValidateContext, + ValidateResult, + ValidatorExecutionResult, +} from './execution.lib' + +export interface PipelineResult { + validatorInstance: AnyInternalValidatorInstance + result: T + schemaResult: any | null + hasSchemaResult?: boolean +} + +interface PendingPipelineResult { + validatorInstance: AnyInternalValidatorInstance + result: T +} + +interface ValidatorPipelineArgs { + context: InputContext + pipeline: ReadonlyArray + hasFailedBefore: boolean + getContext: (inputContext: ValidateContext) => AnyValidatorContext + scope: 'field' | 'form' + validatorInstancesToRun?: ReadonlySet | null + onResult?: (result: PipelineResult) => void +} + +type PendingPromises = Array< + Promise< + PendingPipelineResult< + ValidatorExecutionResult | AbortedCall | ThrownError + > + > +> + +/** + * 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: Map>, + shouldCommitSchemaOutput: boolean, + onResult?: (result: PipelineResult) => void, +): Promise<{ hasErrors: boolean; thrownError: unknown | null }> { + let hasErrors = false + let thrownError: unknown | null = null + + await Promise.all( + pending.map(async (promise) => { + const result = await promise + + const executionResult = result.result + + if (executionResult === ABORTED_CALL) { + return + } + + // Check if this is a thrown error from a validator + if ( + isNotNil(executionResult) && + typeof executionResult === 'object' && + THROWN_ERROR in executionResult + ) { + thrownError = executionResult.error + return + } + + if (isErrorResult(executionResult.result)) { + hasErrors = true + } + + const publicResult: PipelineResult = { + validatorInstance: result.validatorInstance, + result: executionResult.result, + schemaResult: executionResult.schemaResult, + hasSchemaResult: executionResult.hasSchemaResult, + } + + if (shouldCommitSchemaOutput) { + result.validatorInstance.setSchemaOutput(executionResult) + } + results.set(result.validatorInstance, publicResult) + onResult?.(publicResult) + }), + ) + + 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, + hasFailedBefore = false, + getContext, + onResult, + scope, + validatorInstancesToRun = null, +}: ValidatorPipelineArgs): Promise<{ + results: Array> + hasErrors: boolean + thrownError: unknown | null +}> { + let pending: PendingPromises = [] + 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, + shouldCommitSchemaOutput, + onResult, + ) + + pending = [] + hasErrors ||= didError + if (flushedThrownError !== null) { + thrownError = flushedThrownError + } + } + + for (const validatorInstance of pipeline) { + const validator = validatorInstance.definition + + if ( + validatorInstancesToRun && + !validatorInstancesToRun.has(validatorInstance) + ) { + continue + } + + if (!shouldRunValidator(validator, context)) { + continue + } + + if (validator.bailIfInvalid) { + await flush() + + if (hasErrors || thrownError !== null) { + break + } + } + + const promise = runMaybeDebouncedValidator({ + validatorInstance, + context, + onExecute: (ctx) => { + return executeValidator(validator, getContext(ctx), scope) + }, + }).then< + PendingPipelineResult< + ValidatorExecutionResult | AbortedCall | ThrownError + > + >((result) => ({ + validatorInstance, + result, + })) + + pending.push(promise) + } + + await flush() + + return { + results: pipeline.flatMap((validatorInstance) => { + const result = results.get(validatorInstance) + return result ? [result] : [] + }), + hasErrors, + thrownError, + } +} + +interface FormValidatorPipelineArgs { + pipeline: ReadonlyArray + context: FormInputContext + /** + * @private + * Whether previous pipelines have reported an error or not. + */ + hasFailedBefore: boolean + onResult?: (result: PipelineResult>) => void +} + +export interface FormValidatorPipelineResult { + results: Array>> + hasErrors: boolean + thrownError: unknown | null +} + +/** Runs the shared pipeline with form-scoped values and issue parsing. */ +export function runFormValidatorPipeline({ + pipeline, + context, + onResult, + hasFailedBefore, +}: FormValidatorPipelineArgs): Promise { + return runValidatorPipeline>({ + pipeline, + context, + onResult, + hasFailedBefore, + getContext: (ctx) => { + if (isServerValidateContext(ctx)) { + throw new Error('Server validation cannot run through client pipeline') + } + + if (!isFieldValidateContext(ctx)) { + return { + event: ctx.event, + 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, + 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', + }) +} + +interface FieldValidatorPipelineArgs { + pipeline: ReadonlyArray + context: FieldInputContext + onResult?: (result: PipelineResult) => void + /** + * @private + * When an incoming watched field notifies, we should only run validators + * that are actually interested in it. + */ + validatorInstancesToRun?: ReadonlySet | null +} + +export interface FieldValidatorPipelineResult { + results: Array> + hasErrors: boolean + 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, + validatorInstancesToRun = null, +}: FieldValidatorPipelineArgs): Promise { + const fieldApi = context.fieldApi as AnyInternalFieldApi + + if (fieldApi._isKilled) + return Promise.resolve({ + results: [], + hasErrors: false, + thrownError: null, + }) + + return runValidatorPipeline({ + pipeline, + context, + onResult, + // No use case for configuring this outside of field pipeline yet + hasFailedBefore: false, + getContext: (ctx) => { + if (isServerValidateContext(ctx)) { + throw new Error('Server validation cannot run through field pipeline') + } + + return { + event: context.event, + formApi: context.formApi, + signal: ctx.signal, + fieldApi: context.fieldApi, + value: context.fieldApi.value, + parseIssues: parseFieldIssues, + } + }, + scope: 'field', + validatorInstancesToRun, + }) +} diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 3af94d9399..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', () => { @@ -48,7 +49,7 @@ describe('field - lifecycle', () => { expect(field._validatorInstances?.[0]).toBe(instance) expect(instance?.definition).toBe(nextDefinition) expect(instance?.owner).toBe(field) - expect(instance?.scope).toBe('field') + expect(instance?.scope).toBe(validationSourceScopes.field) expect(instance?.revision).toBe((initialRevision ?? 0) + 1) field._update({}) @@ -71,7 +72,10 @@ describe('field - lifecycle', () => { const instance = field._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput('output') + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) field.reset() @@ -255,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 { @@ -334,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, }, @@ -460,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 296158afe8..1964f67fe6 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() @@ -394,14 +402,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 f35ad38614..c74522930b 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', () => { @@ -179,10 +181,33 @@ describe('form - lifecycle', () => { expect(form._validatorInstances?.[0]).toBe(instance) expect(instance?.definition).toBe(nextDefinition) expect(instance?.owner).toBe(form) - expect(instance?.scope).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: '' }, @@ -191,7 +216,10 @@ describe('form - lifecycle', () => { const instance = form._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput('output') + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) form.reset() @@ -378,6 +406,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) => ({ @@ -385,8 +418,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) @@ -457,10 +494,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 () => { @@ -615,11 +662,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..8d3315c56e 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -402,7 +402,6 @@ describe('form - validation', () => { triggers: ['blur'], }, { - // eslint-disable-next-line @typescript-eslint/require-await run: async () => ({ message: 'Async error' }), triggers: ['blur'], }, @@ -439,7 +438,6 @@ describe('form - validation', () => { triggers: ['blur'], }, { - // eslint-disable-next-line @typescript-eslint/require-await run: async () => ({ message: 'Async error' }), triggers: ['blur'], }, @@ -1655,6 +1653,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 dde2a4f7cd..c10dea9ab2 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', () => { @@ -336,7 +337,7 @@ describe('FormGroupApi', () => { expect(group._validatorInstances?.[0]).toBe(instance) expect(instance?.definition).toBe(nextDefinition) expect(instance?.owner).toBe(group) - expect(instance?.scope).toBe('group') + expect(instance?.scope).toBe(validationSourceScopes.group) expect(instance?.revision).toBe(1) }) @@ -372,7 +373,10 @@ describe('FormGroupApi', () => { const instance = group._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput('output') + instance?.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) group._cleanup() group.mount() @@ -484,14 +488,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 () => { @@ -838,6 +843,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) => { @@ -1087,10 +1125,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) }) @@ -1146,13 +1188,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 index 7c3d6c6c2c..9bf5dc10a8 100644 --- a/packages/form-core/tests/ValidatorInstance.spec.ts +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -4,6 +4,7 @@ import { InternalValidatorInstance, reconcileValidatorInstances, } from '../src/ValidatorInstance.lib' +import { validationSourceScopes } from '../src/ValidationSourceInstance.lib' type TestDebouncedFn = (value: string) => void @@ -35,6 +36,15 @@ describe('InternalValidatorInstance', () => { 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() @@ -42,7 +52,8 @@ describe('InternalValidatorInstance', () => { expect(first).not.toBe(second) expect(first.definition.run()).toEqual({ message: 'initial' }) expect(first.owner).toEqual({ name: 'name' }) - expect(first.scope).toBe('field') + expect(first.scope).toBe(validationSourceScopes.field) + expect(first.index).toBe(0) expect(first.abortController).toBeNull() expect(first.debouncer).toBeNull() expect(first.schemaOutput).toBeUndefined() @@ -58,6 +69,9 @@ describe('InternalValidatorInstance', () => { first.setResolvedWatchField('temporary', { name: 'temporary' }) first.deleteResolvedWatchField('temporary') + expect(first.errorTargets).toBeNull() + expect(first.resolvedWatchFields).toBeNull() + expectTypeOf(first.definition).toEqualTypeOf< ReturnType >() @@ -82,7 +96,10 @@ describe('InternalValidatorInstance', () => { const watchedField = { name: 'source' } instance.setAbortController(abortController) - instance.setSchemaOutput('output') + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', watchedField) instance.markMountValidationRan() @@ -159,7 +176,17 @@ describe('InternalValidatorInstance', () => { it('distinguishes an unset schema output from an undefined output', () => { const instance = createInstance() - instance.setSchemaOutput(undefined) + 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) @@ -180,7 +207,10 @@ describe('InternalValidatorInstance', () => { instance.updateDefinition(definition) instance.setAbortController(abortController) const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) - instance.setSchemaOutput('output') + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', watchedField) instance.markMountValidationRan() @@ -200,7 +230,7 @@ describe('InternalValidatorInstance', () => { expect(instance.didRunOnMount).toBe(true) expect(instance.definition).toBe(definition) expect(instance.owner).toBe(owner) - expect(instance.scope).toBe('field') + expect(instance.scope).toBe(validationSourceScopes.field) expect(instance.revision).toBe(1) expect(instance.disposed).toBe(false) }) @@ -214,16 +244,27 @@ describe('InternalValidatorInstance', () => { instance.setAbortController(abortController) const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) - instance.setSchemaOutput('output') + instance.setSchemaOutput({ + schemaResult: 'output', + hasSchemaResult: true, + }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', { name: 'source' }) instance.markMountValidationRan() debouncer?.maybeExecute('cancelled') - instance.dispose() - instance.dispose() + 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() @@ -241,7 +282,10 @@ describe('InternalValidatorInstance', () => { instance.setAbortController(nextController) instance.clearAbortController(nextController) const nextDebouncer = instance.getOrCreateDebouncer(nextDebouncedFn, 100) - instance.setSchemaOutput('ignored') + instance.setSchemaOutput({ + schemaResult: 'ignored', + hasSchemaResult: true, + }) instance.clearSchemaOutput() instance.addErrorTarget('ignored') instance.deleteErrorTarget('target') @@ -314,6 +358,31 @@ describe('reconcileValidatorInstances', () => { 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') @@ -338,7 +407,9 @@ describe('reconcileValidatorInstances', () => { expect(firstInstance?.revision).toBe(1) expect(secondInstance?.definition).toBe(secondDefinition) expect(secondInstance?.owner).toBe(owner) - expect(secondInstance?.scope).toBe('group') + expect(secondInstance?.scope).toBe(validationSourceScopes.group) + expect(firstInstance?.index).toBe(0) + expect(secondInstance?.index).toBe(1) expect( reconcileValidatorInstances({ diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index e8bc722b37..c8b563d721 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' @@ -140,6 +141,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 +159,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 +497,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 new file mode 100644 index 0000000000..522472593e --- /dev/null +++ b/packages/form-core/tests/validation-errors.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isErrorResult, + isValidationErrorMap, + 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> = [ + null, + undefined, + false, + [], + ] + + for (const result of validResults) { + expect(parseValidationResult(result)).toEqual({ + self: null, + subfields: null, + }) + expect(isErrorResult(result)).toBe(false) + } + }) + + it('normalizes errors owned by the validation boundary', () => { + const result = ['Required', { message: 'Must be valid' }] + + expect(parseValidationResult(result)).toEqual({ + self: [{ message: 'Required' }, { message: 'Must be valid' }], + subfields: null, + }) + expect(isErrorResult(result)).toBe(true) + }) + + it('does not misinterpret an issue with fields metadata as an error map', () => { + const result = { message: 'Required', fields: {} } + + expect(isValidationErrorMap(result)).toBe(false) + expect(parseValidationResult(result)).toEqual({ + self: [result], + subfields: null, + }) + expect(isErrorResult(result)).toBe(true) + }) + + it('normalizes and prunes error maps', () => { + const result = { + form: 'Form is invalid', + fields: { + name: 'Name is required', + age: [], + email: undefined, + }, + } + + expect(isValidationErrorMap(result)).toBe(true) + expect(parseValidationResult(result)).toEqual({ + self: [{ message: 'Form is invalid' }], + subfields: { + name: [{ message: 'Name is required' }], + }, + }) + expect(isErrorResult(result)).toBe(true) + }) + + it('recognizes error maps with additional metadata keys', () => { + const result = { + form: 'Form is invalid', + fields: { name: 'Name is required' }, + source: 'server', + } + + expect(isValidationErrorMap(result)).toBe(true) + expect(parseValidationResult(result)).toEqual({ + self: [{ message: 'Form is invalid' }], + subfields: { + name: [{ message: 'Name is required' }], + }, + }) + expect(isErrorResult(result)).toBe(true) + }) + + it('preserves an empty error map without storing an error', () => { + const result = { fields: {} } + const resultWithEmptyEntries = { + form: [], + fields: { + name: undefined, + age: [], + }, + } + + expect(parseValidationResult(result)).toEqual({ + self: null, + subfields: {}, + }) + expect(parseValidationResult(resultWithEmptyEntries)).toEqual({ + self: null, + subfields: {}, + }) + expect(isErrorResult(result)).toBe(false) + expect(isErrorResult(resultWithEmptyEntries)).toBe(false) + }) +}) + +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( + validatorInstance, + [[field, errors]], + undefined, + setFieldError, + vi.fn(), + ) + + 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( + validatorInstance, + [], + undefined, + vi.fn(), + vi.fn(), + ) + + expect(result.didFieldRefsChange).toBe(false) + expect(result.fieldRefs.size).toBe(0) + expect(result.affectedFields.size).toBe(0) + }) + + it('reports unchanged refs when the old field ref set is empty', () => { + const validatorInstance = createTestValidatorInstance() + const result = reconcileRoutedFieldErrors( + validatorInstance, + [], + new Set(), + vi.fn(), + vi.fn(), + ) + + expect(result.didFieldRefsChange).toBe(false) + }) + + 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( + validatorInstance, + [], + new Set([field]), + vi.fn(), + clearFieldError, + ) + + expect(result.didFieldRefsChange).toBe(true) + expect(result.affectedFields).toEqual(new Set([field])) + expect(clearFieldError).toHaveBeenCalledWith(field, validatorInstance) + }) +}) diff --git a/packages/form-core/tests/validation.test.ts b/packages/form-core/tests/validation-pipeline.test.ts similarity index 81% rename from packages/form-core/tests/validation.test.ts rename to packages/form-core/tests/validation-pipeline.test.ts index 805fdf84bf..87131fa474 100644 --- a/packages/form-core/tests/validation.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -1,22 +1,12 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import { - isErrorResult, - isValidationErrorMap, - parseValidationResult, - reconcileRoutedFieldErrors, runFieldValidatorPipeline, runFormValidatorPipeline, -} from '../src/validation.lib' -import { - createErrorMap, - createErrorVisibility, - createValidator, - createValidators, - formOptions, -} from '../src' +} from '../src/validation' import { InternalFormApi } from '../src/FormApi/FormApi.lib' -import type { PipelineResult } from '../src/validation.lib' +import { reconcileValidatorInstances } from '../src/ValidatorInstance.lib' +import type { PipelineResult } from '../src/validation' import type { ClientValidationTrigger, DeepKeys, @@ -31,248 +21,7 @@ import type { ValidationPredicateFn, } from '../src' import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' - -describe('validation public helpers', () => { - it('returns form options unchanged at runtime', () => { - const options = { defaultValues: { name: 'Ada' } } - - expect(formOptions(options)).toBe(options) - expect(formOptions.strictSchema(options)).toBe(options) - expect(formOptions.looseSchema(options)).toBe(options) - }) - - it('creates validators by pairing options with run functions', () => { - const run = () => null - const validator = createValidator({ - bailIfInvalid: true, - triggers: ['change'], - })(run) - - expect(validator).toEqual({ - bailIfInvalid: true, - triggers: ['change'], - run, - }) - }) - - it('creates multiple validators from option and run tuples', () => { - const firstRun = () => null - const secondRun = () => ({ message: 'Required' }) - - const validators = createValidators([ - { triggers: ['change'] }, - { bailIfInvalid: true, triggers: ['blur'] }, - ])(firstRun, secondRun) - - expect(validators).toEqual([ - { triggers: ['change'], run: firstRun }, - { bailIfInvalid: true, triggers: ['blur'], run: secondRun }, - ]) - }) - - it('returns reusable error visibility callbacks unchanged', () => { - const visibility = () => true - - expect(createErrorVisibility(visibility)).toBe(visibility) - }) - - it('creates mutable validation error maps', () => { - const errors = createErrorMap<{ name: string; age: number }>() - - expect(errors).toEqual({ fields: {} }) - errors.fields.name = undefined - errors.fields.age = 'Age is required' - errors.form = 'Form is invalid' - - expect(errors).toEqual({ - form: 'Form is invalid', - fields: { name: undefined, age: 'Age is required' }, - }) - }) - - it('returns the prefilled validation error map', () => { - const initial = { - form: 'Form is invalid', - fields: { name: 'Name is required' }, - } - - const errors = createErrorMap(initial) - - expect(errors).toBe(initial) - }) - - it('preserves falsy form errors in the initial error map', () => { - const initial = { - form: '', - fields: {}, - } - - const errors = createErrorMap(initial) - - expect(errors).toBe(initial) - expect(errors).toHaveProperty('form', '') - }) -}) - -describe('parseValidationResult', () => { - it('returns no stored errors for valid results', () => { - const validResults: Array> = [ - null, - undefined, - false, - [], - ] - - for (const result of validResults) { - expect(parseValidationResult(result)).toEqual({ - self: null, - subfields: null, - }) - expect(isErrorResult(result)).toBe(false) - } - }) - - it('normalizes errors owned by the validation boundary', () => { - const result = ['Required', { message: 'Must be valid' }] - - expect(parseValidationResult(result)).toEqual({ - self: [{ message: 'Required' }, { message: 'Must be valid' }], - subfields: null, - }) - expect(isErrorResult(result)).toBe(true) - }) - - it('does not misinterpret an issue with fields metadata as an error map', () => { - const result = { message: 'Required', fields: {} } - - expect(isValidationErrorMap(result)).toBe(false) - expect(parseValidationResult(result)).toEqual({ - self: [result], - subfields: null, - }) - expect(isErrorResult(result)).toBe(true) - }) - - it('normalizes and prunes error maps', () => { - const result = { - form: 'Form is invalid', - fields: { - name: 'Name is required', - age: [], - email: undefined, - }, - } - - expect(isValidationErrorMap(result)).toBe(true) - expect(parseValidationResult(result)).toEqual({ - self: [{ message: 'Form is invalid' }], - subfields: { - name: [{ message: 'Name is required' }], - }, - }) - expect(isErrorResult(result)).toBe(true) - }) - - it('recognizes error maps with additional metadata keys', () => { - const result = { - form: 'Form is invalid', - fields: { name: 'Name is required' }, - source: 'server', - } - - expect(isValidationErrorMap(result)).toBe(true) - expect(parseValidationResult(result)).toEqual({ - self: [{ message: 'Form is invalid' }], - subfields: { - name: [{ message: 'Name is required' }], - }, - }) - expect(isErrorResult(result)).toBe(true) - }) - - it('preserves an empty error map without storing an error', () => { - const result = { fields: {} } - const resultWithEmptyEntries = { - form: [], - fields: { - name: undefined, - age: [], - }, - } - - expect(parseValidationResult(result)).toEqual({ - self: null, - subfields: {}, - }) - expect(parseValidationResult(resultWithEmptyEntries)).toEqual({ - self: null, - subfields: {}, - }) - expect(isErrorResult(result)).toBe(false) - expect(isErrorResult(resultWithEmptyEntries)).toBe(false) - }) -}) - -describe('reconcileRoutedFieldErrors', () => { - it('sets errors on already-resolved field refs', () => { - const field = { name: 'name' } as AnyInternalFieldApi - const errors = [{ message: 'Name is required' }] - const setFieldError = vi.fn() - const result = reconcileRoutedFieldErrors( - 2, - [[field, errors]], - undefined, - setFieldError, - vi.fn(), - ) - - expect(setFieldError).toHaveBeenCalledWith(field, 2, 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 result = reconcileRoutedFieldErrors( - 0, - [], - undefined, - vi.fn(), - vi.fn(), - ) - - expect(result.didFieldRefsChange).toBe(false) - expect(result.fieldRefs.size).toBe(0) - expect(result.affectedFields.size).toBe(0) - }) - - it('reports unchanged refs when the old field ref set is empty', () => { - const result = reconcileRoutedFieldErrors( - 0, - [], - new Set(), - vi.fn(), - vi.fn(), - ) - - expect(result.didFieldRefsChange).toBe(false) - }) - - it('clears stale old field refs when no new refs replace them', () => { - const field = { name: 'name' } as AnyInternalFieldApi - const clearFieldError = vi.fn() - const result = reconcileRoutedFieldErrors( - 0, - [], - new Set([field]), - vi.fn(), - clearFieldError, - ) - - expect(result.didFieldRefsChange).toBe(true) - expect(result.affectedFields).toEqual(new Set([field])) - expect(clearFieldError).toHaveBeenCalledWith(field, 0) - }) -}) +import type { StandardSchemaV1 } from '../src/standardSchema.public' describe('runFormValidatorPipeline', () => { type Event = Exclude['event'], 'server'> @@ -285,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 @@ -302,7 +57,7 @@ describe('runFormValidatorPipeline', () => { }, hasFailedBefore: args.hasFailedBefore ?? false, onResult: args.onResult, - pipeline: pipeline, + pipeline: validatorInstances, }).then((res) => res.results) }, } @@ -317,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 @@ -331,7 +92,7 @@ describe('runFormValidatorPipeline', () => { fieldApi: field, }, onResult: args.onResult, - pipeline, + pipeline: validatorInstances, }).then((res) => res.results) }, } @@ -559,8 +320,8 @@ describe('runFormValidatorPipeline', () => { 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') @@ -571,7 +332,7 @@ describe('runFormValidatorPipeline', () => { return 100 }) - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext } = getPipeline(form, [ { run, triggers: ['change'], @@ -863,7 +624,7 @@ describe('runFormValidatorPipeline', () => { const run = vi.fn(() => validationResult) const onResult = vi.fn() - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext, pipeline } = getPipeline(formApi, [ { run, triggers: ['change'], @@ -886,7 +647,7 @@ describe('runFormValidatorPipeline', () => { expect(onResult).toHaveBeenCalledOnce() expect(onResult).toHaveBeenCalledWith( expect.objectContaining({ - validatorIndex: 0, + validatorInstance: pipeline[0], result: validationResult, }), ) @@ -1072,7 +833,7 @@ describe('runFormValidatorPipeline', () => { age: z.number().min(0), }) - const { runWithContext } = getPipeline(formApi, [ + const { runWithContext, pipeline } = getPipeline(formApi, [ { run: schema, triggers: [], @@ -1087,6 +848,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 () => { @@ -1266,7 +1186,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-core/tests/validation-public.test.ts b/packages/form-core/tests/validation-public.test.ts new file mode 100644 index 0000000000..8e826f20df --- /dev/null +++ b/packages/form-core/tests/validation-public.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + createErrorMap, + createErrorVisibility, + createValidator, + createValidators, + formOptions, +} from '../src' + +describe('validation public helpers', () => { + it('returns form options unchanged at runtime', () => { + const options = { defaultValues: { name: 'Ada' } } + + expect(formOptions(options)).toBe(options) + expect(formOptions.strictSchema(options)).toBe(options) + expect(formOptions.looseSchema(options)).toBe(options) + }) + + it('creates validators by pairing options with run functions', () => { + const run = () => null + const validator = createValidator({ + bailIfInvalid: true, + triggers: ['change'], + })(run) + + expect(validator).toEqual({ + bailIfInvalid: true, + triggers: ['change'], + run, + }) + }) + + it('creates multiple validators from option and run tuples', () => { + const firstRun = () => null + const secondRun = () => ({ message: 'Required' }) + + const validators = createValidators([ + { triggers: ['change'] }, + { bailIfInvalid: true, triggers: ['blur'] }, + ])(firstRun, secondRun) + + expect(validators).toEqual([ + { triggers: ['change'], run: firstRun }, + { bailIfInvalid: true, triggers: ['blur'], run: secondRun }, + ]) + }) + + it('returns reusable error visibility callbacks unchanged', () => { + const visibility = () => true + + expect(createErrorVisibility(visibility)).toBe(visibility) + }) + + it('creates mutable validation error maps', () => { + const errors = createErrorMap<{ name: string; age: number }>() + + expect(errors).toEqual({ fields: {} }) + errors.fields.name = undefined + errors.fields.age = 'Age is required' + errors.form = 'Form is invalid' + + expect(errors).toEqual({ + form: 'Form is invalid', + fields: { name: undefined, age: 'Age is required' }, + }) + }) + + it('returns the prefilled validation error map', () => { + const initial = { + form: 'Form is invalid', + fields: { name: 'Name is required' }, + } + + const errors = createErrorMap(initial) + + expect(errors).toBe(initial) + }) + + it('preserves falsy form errors in the initial error map', () => { + const initial = { + form: '', + fields: {}, + } + + const errors = createErrorMap(initial) + + expect(errors).toBe(initial) + expect(errors).toHaveProperty('form', '') + }) +}) 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, + })) +} From 013a0240340bf1efe6d321a3f48aa28893b49b51 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:55:31 +0200 Subject: [PATCH 05/12] chore: add changeset --- .changeset/shy-hairs-follow.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/shy-hairs-follow.md 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 From 94a444d499403c9b070d344ef13bdec034ba2a6d Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:54:02 +0200 Subject: [PATCH 06/12] chore: merge alpha --- .changeset/odd-comics-push.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/odd-comics-push.md diff --git a/.changeset/odd-comics-push.md b/.changeset/odd-comics-push.md new file mode 100644 index 0000000000..25de60ef11 --- /dev/null +++ b/.changeset/odd-comics-push.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix: Schema output type now properly guards against dynamically enabled validators From 4090fef5bc6bd25ac9634cbe56f83650f05e951b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:02:28 +0200 Subject: [PATCH 07/12] fix: catch mount runtime exceptions --- .../form-core/src/validation/mount.lib.ts | 8 +++ .../tests/FormApi/validation.spec.ts | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index ed56098bf8..0b82d813d3 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -109,6 +109,10 @@ function executeMountValidator( return result }) + .catch((error) => { + console.error(error) + return createEmptyMountValidationResult() + }) .finally(cleanup) as unknown as PromiseLike< MountValidationExecutionResult > @@ -129,6 +133,10 @@ function executeMountValidator( hasSchemaResult: false, } }) + .catch((error) => { + console.error(error) + return createEmptyMountValidationResult() + }) .finally(cleanup) } diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 8d3315c56e..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', () => { @@ -798,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') From f18eada797011eb503582ddc0d7ba893217013d3 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:05:14 +0200 Subject: [PATCH 08/12] fix: settle disposed debounced validators --- .../form-core/src/validation/execution.lib.ts | 7 ++++++- .../tests/validation-pipeline.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index f272089a7b..150d0c4aeb 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -452,7 +452,12 @@ export function runMaybeDebouncedValidator({ }) }, debounceMs) - debouncer?.maybeExecute({ + if (!debouncer) { + settle(ABORTED_CALL) + return + } + + debouncer.maybeExecute({ context: validationContext, resolve: settle, // This should not be called anymore since we handle errors in the diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 87131fa474..6c86fc08d1 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -317,6 +317,27 @@ 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() From be8c637d0418f0ca977947eb7c59faa66610ff04 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:10:21 +0200 Subject: [PATCH 09/12] fix: avoid runtime errors leaving stale ssr instances --- packages/form-core/src/ssr.lib.ts | 112 +++++++++--------- .../form-core/tests/serverValidate.spec.ts | 57 +++++++++ 2 files changed, 114 insertions(+), 55 deletions(-) diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 229b544ec1..69f9d00b7e 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -141,64 +141,66 @@ export async function validateServerValues< } } - 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) { - validatorInstances?.forEach((instance) => instance.dispose()) - throw pipelineResult.thrownError - } - - 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, - })) + 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 + } - validatorInstances?.forEach((instance) => instance.dispose()) + 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, - 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/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index c8b563d721..b2c6fa92bb 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -68,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( From c71c654b643b7f638851dd5b372e4f0edbb0256a Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:32:03 +0200 Subject: [PATCH 10/12] chore: fix minor nitpicks --- packages/form-core/src/FormApi/FormApi.lib.ts | 16 +++++++++------- .../form-core/src/FormApi/handleSubmit.lib.ts | 2 -- packages/form-core/src/ssr.lib.ts | 13 +++++++------ .../form-core/tests/FieldApi/validation.spec.ts | 4 +++- .../form-core/tests/FormApi/lifecycle.spec.ts | 6 +++++- .../tests/FormGroupApi/FormGroupApi.spec.ts | 6 +++++- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 8acbba1195..54d2cb04d8 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -686,15 +686,17 @@ export class InternalFormApi< } _clearSubmitErrors(field: AnyInternalFieldApi | null): void { - this._setFormValidationSourceError(this._onSubmitSource, [], '') + batch(() => { + this._setFormValidationSourceError(this._onSubmitSource, [], '') - if (!field || !this._onSubmitSource.errorTargets?.has(field)) return + if (!field || !this._onSubmitSource.errorTargets?.has(field)) return - this._clearFieldValidationSourceError(field, this._onSubmitSource) - this._atoms.meta.errorFields.set((prev) => - reconcileFormErrorFields(prev, [field]), - ) - field._pruneIfUnused() + this._clearFieldValidationSourceError(field, this._onSubmitSource) + this._atoms.meta.errorFields.set((prev) => + reconcileFormErrorFields(prev, [field]), + ) + field._pruneIfUnused() + }) } _tryGetFieldApi( diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index c84aa8a749..b35a39deb6 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -196,8 +196,6 @@ export async function runSubmissionProcess( if (isErrorResult(submissionData.submitError)) { submissionData.hasFailed = true errorResults.push(submissionData.submitError) - - form._processSubmitValidationResult(submissionData.submitError, 'submit') } }) diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 69f9d00b7e..7768f53341 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -126,12 +126,6 @@ export async function validateServerValues< values: TFormData, ): Promise> { const pipeline = options.validators - const validatorInstances = reconcileValidatorInstances({ - definitions: pipeline, - instances: null, - owner: options, - scope: 'form', - }) if (!pipeline || pipeline.length === 0) { return { @@ -141,6 +135,13 @@ export async function validateServerValues< } } + const validatorInstances = reconcileValidatorInstances({ + definitions: pipeline, + instances: null, + owner: options, + scope: 'form', + }) + try { const pipelineResult = await runValidatorPipeline< ServerFormValidateResult diff --git a/packages/form-core/tests/FieldApi/validation.spec.ts b/packages/form-core/tests/FieldApi/validation.spec.ts index 1964f67fe6..246c06e646 100644 --- a/packages/form-core/tests/FieldApi/validation.spec.ts +++ b/packages/form-core/tests/FieldApi/validation.spec.ts @@ -386,7 +386,9 @@ describe('field - linked validators', () => { expect(firstValidator).toHaveBeenCalledOnce() expect(secondValidator).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('cyclical validator cycle detected'), + ) warn.mockRestore() }) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index c74522930b..7d65926a2d 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -160,7 +160,11 @@ describe('form - lifecycle', () => { ], }) - expect(warn).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) warn.mockRestore() }) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index c10dea9ab2..25c563bedb 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -357,7 +357,11 @@ describe('FormGroupApi', () => { validators: [{ run: () => null, triggers: [] }], }) - expect(warn).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) warn.mockRestore() }) From d6ad6e784773c28e18170ed57c8fb1a37501f1e6 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:37:05 +0200 Subject: [PATCH 11/12] chore: refactor some snippets for readability --- .../form-core/src/validation/mount.lib.ts | 10 +++---- .../form-core/src/validation/pipeline.lib.ts | 29 ++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 0b82d813d3..419e555e9b 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -165,7 +165,7 @@ async function continueMountValidationFromAsyncResult< pipeline: ReadonlyArray, getContext: MountValidatorPipelineArgs['getContext'], scope: 'field' | 'form', - startInstance: AnyInternalValidatorInstance, + startIndex: number, firstResult: PromiseLike>, hasFailedBefore: boolean, onResult?: (result: PipelineResult) => void, @@ -175,7 +175,7 @@ async function continueMountValidationFromAsyncResult< const firstExecutionResult = await firstResult if ( processMountValidationExecutionResult( - startInstance, + pipeline[startIndex]!, firstExecutionResult, onResult, ) @@ -183,7 +183,6 @@ async function continueMountValidationFromAsyncResult< hasFailed = true } - const startIndex = pipeline.indexOf(startInstance) for (let i = startIndex + 1; i < pipeline.length; i++) { const validatorInstance = pipeline[i]! const validator = validatorInstance.definition @@ -240,7 +239,8 @@ function runMountValidatorPipeline({ let hasFailed = false - for (const validatorInstance of pipeline) { + for (let i = 0; i < pipeline.length; i++) { + const validatorInstance = pipeline[i]! const validator = validatorInstance.definition if (validator.runOnMount !== true) continue @@ -264,7 +264,7 @@ function runMountValidatorPipeline({ pipeline, getContext, scope, - validatorInstance, + i, result, hasFailed, onResult, diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index 44236399a7..9ba77b800b 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -257,27 +257,28 @@ export function runFormValidatorPipeline({ 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', From ed6bdc189a94316987ec0350ecde35773ae6cd12 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:47:24 +0200 Subject: [PATCH 12/12] chore: add unit test coverage --- .../tests/validation-pipeline.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 6c86fc08d1..c9443d8a65 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -116,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, [