diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 927e40dd38..f03944a444 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -8,7 +8,7 @@ import { runFieldMountValidatorPipeline, runFieldValidatorPipeline, setIndexedError, -} from '../validation.lib' +} from '../validation' import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' import { @@ -46,7 +46,7 @@ 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' diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index e6bd0edab8..679ef49276 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -33,7 +33,7 @@ import { runFormMountValidatorPipeline, runFormValidatorPipeline, setIndexedError, -} from '../validation.lib' +} from '../validation' import { runFormListenerPipeline } from '../listeners.lib' import { applyServerState } from '../ssr.lib' import { devtools } from '../devtoolsBridge.lib' @@ -54,10 +54,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, diff --git a/packages/form-core/src/FormApi/formState.lib.ts b/packages/form-core/src/FormApi/formState.lib.ts index 7e5ad6040a..8758455b17 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -3,7 +3,7 @@ import { clearIndexedErrorsFromSource, hasIndexedErrorFromSource, hasIndexedErrors, -} from '../validation.lib' +} from '../validation' import type { FormState } from './FormApi.public' import type { InternalFormApi } from './FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index d5907202b8..cf278b212d 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' diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 93271cc56b..4292e6e76f 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -17,7 +17,7 @@ import { runGroupMountValidatorPipeline, runValidatorPipeline, setIndexedError, -} from '../validation.lib' +} from '../validation' import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' import { visitFieldSubtree } from '../FieldApi/fieldTraversal.lib' import { diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index 2e7ac143a4..f4303d67e8 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -7,7 +7,7 @@ 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 './listeners.lib' export * from './FieldApi/linked-fields.lib' export * from './standardSchema.lib' diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 9865435d04..0cef8887dd 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -3,7 +3,7 @@ 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 type { FormOptions } from './FormApi/FormApi.public' diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index 3c4533d14b..c025fc0c93 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -2,7 +2,7 @@ import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' // type import type { FieldUpdateOptions, OneOrMany, Updater } from './types.public' -import type { ValidationDebouncer } from './validation.lib' +import type { ValidationDebouncer } from './validation' import type { FieldValidateResult, FormGroupValidateResult, 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..c85138b44f --- /dev/null +++ b/packages/form-core/src/validation/errors.lib.ts @@ -0,0 +1,232 @@ +import { evaluate, isNil, normalizeToArray } from '../utils.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 +} + +/** + * @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), + } +} 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..d2d5d7765d --- /dev/null +++ b/packages/form-core/src/validation/execution.lib.ts @@ -0,0 +1,472 @@ +import { LiteDebouncer } from '@tanstack/pacer-lite' +import { + isStandardSchema, + parseStandardSchema, + parseStandardSchemaIssues, +} from '../standardSchema.lib' +import type { PipelineCache } from '../utils.lib' +import type { + FieldValidateResult, + FieldValidator, + FieldValidatorContext, + FormGroupValidateResult, + FormGroupValidator, + FormGroupValidatorContext, + FormValidateResult, + FormValidator, + FormValidatorContext, + ServerFormValidatorContext, + ValidationPredicateContext, + ValidationTriggerOption, + Validator, +} from '../validation.public' +import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.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' } +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 +export type AnyPipelineValidator = + | FormValidator + | FormGroupValidator + | FieldValidator + | Validator +export type AnyValidatorContext = + | FormValidatorContext + | ServerFormValidatorContext + | FormGroupValidatorContext + | FieldValidatorContext + +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' +} + +export function isServerValidateContext( + ctx: ValidateContext, +): ctx is ServerPipelineValidateContext { + return ctx.event === 'server' +} + +export 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, + } +} + +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 +} + +export type ValidationDebouncer = LiteDebouncer< + (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)) +} + +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) +} + +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), + ) +} + +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, +> { + 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() +} + +export 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 +} + +export 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: () => {}, + }) + }) +} diff --git a/packages/form-core/src/validation/index.ts b/packages/form-core/src/validation/index.ts new file mode 100644 index 0000000000..eb82a015be --- /dev/null +++ b/packages/form-core/src/validation/index.ts @@ -0,0 +1,34 @@ +export { + clearIndexedErrorsFromSource, + hasIndexedErrorFromSource, + hasIndexedErrors, + isErrorResult, + isValidationErrorMap, + normalizeValidationError, + parseValidationResult, + reconcileRoutedFieldErrors, + setIndexedError, +} from './errors.lib' +export type { ParsedValidationResult } from './errors.lib' +export { isValidationTriggerEnabled } from './execution.lib' +export type { + InputContext, + ValidateContext, + ValidationDebouncer, +} 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..e2526c39ee --- /dev/null +++ b/packages/form-core/src/validation/mount.lib.ts @@ -0,0 +1,336 @@ +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 { PipelineCache } from '../utils.lib' +import type { + FieldValidateResult, + FieldValidator, + FormGroupValidateResult, + FormGroupValidator, + FormValidateResult, + FormValidator, +} from '../validation.public' +import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' +import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' +import type { + AnyPipelineValidator, + AnyValidatorContext, + ValidateResult, +} from './execution.lib' +import type { 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 + 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 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/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts new file mode 100644 index 0000000000..d46fce94cd --- /dev/null +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -0,0 +1,316 @@ +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 { PipelineCache } from '../utils.lib' +import type { + FieldValidateResult, + FieldValidator, + FormValidateResult, + FormValidator, +} from '../validation.public' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' +import type { + AbortedCall, + AnyPipelineValidator, + AnyValidatorContext, + FieldInputContext, + FormInputContext, + InputContext, + ThrownError, + ValidateContext, + ValidateResult, + ValidatorExecutionResult, +} from './execution.lib' + +export interface PipelineResult { + validatorIndex: number + result: T + schemaResult: any | null + hasSchemaResult?: boolean +} + +interface PendingPipelineResult { + validatorIndex: number + result: T +} + +interface ValidatorPipelineArgs { + context: InputContext + cache: PipelineCache + pipeline: ReadonlyArray + hasFailedBefore: boolean + getContext: (inputContext: ValidateContext) => AnyValidatorContext + scope: 'field' | 'form' + validatorIndecesToRun?: Array | null + onResult?: (result: PipelineResult) => void +} + +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 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, + }) +} 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..701f3afb78 --- /dev/null +++ b/packages/form-core/tests/validation-errors.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isErrorResult, + isValidationErrorMap, + parseValidationResult, + reconcileRoutedFieldErrors, +} from '../src/validation' +import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' + +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) + }) +}) diff --git a/packages/form-core/tests/validation.test.ts b/packages/form-core/tests/validation-pipeline.test.ts similarity index 82% rename from packages/form-core/tests/validation.test.ts rename to packages/form-core/tests/validation-pipeline.test.ts index 805fdf84bf..caf4fb2a00 100644 --- a/packages/form-core/tests/validation.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -1,22 +1,11 @@ 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 type { PipelineResult } from '../src/validation' import type { ClientValidationTrigger, DeepKeys, @@ -32,248 +21,6 @@ import type { } 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) - }) -}) - describe('runFormValidatorPipeline', () => { type Event = Exclude['event'], 'server'> 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', '') + }) +})