From 8b200efbbd36df58e1f6e60b07cc524415ec09de Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:13:29 +0100 Subject: [PATCH 01/15] refactor: set up abstract async manager --- .../src/pipeline/baseInstance.lib.ts | 17 ++ .../form-core/src/pipeline/runPipeline.lib.ts | 260 ++++++++++++++++++ packages/form-core/src/pipeline/state.lib.ts | 149 ++++++++++ packages/form-core/src/pipeline/utils.lib.ts | 38 +++ .../form-core/src/validation/mount.lib.ts | 5 +- 5 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 packages/form-core/src/pipeline/baseInstance.lib.ts create mode 100644 packages/form-core/src/pipeline/runPipeline.lib.ts create mode 100644 packages/form-core/src/pipeline/state.lib.ts create mode 100644 packages/form-core/src/pipeline/utils.lib.ts diff --git a/packages/form-core/src/pipeline/baseInstance.lib.ts b/packages/form-core/src/pipeline/baseInstance.lib.ts new file mode 100644 index 000000000..16936d638 --- /dev/null +++ b/packages/form-core/src/pipeline/baseInstance.lib.ts @@ -0,0 +1,17 @@ +import type { Awaitable } from '../types.public' + +export abstract class BaseInstance { + /** + * Cancel this instance's current task, if present. + */ + cancelCurrentExecution: (() => void) | null = null + + /** + * Builds the run context, executes this instance, and normalizes its result. + * + * @returns This instance's normalized result directly for synchronous work, + * or a promise of that result for asynchronous work. The manager attaches + * instance identity when collecting results. + */ + abstract execute(input: TInput, signal: AbortSignal): Awaitable +} diff --git a/packages/form-core/src/pipeline/runPipeline.lib.ts b/packages/form-core/src/pipeline/runPipeline.lib.ts new file mode 100644 index 000000000..b2d23f8d8 --- /dev/null +++ b/packages/form-core/src/pipeline/runPipeline.lib.ts @@ -0,0 +1,260 @@ +import { isPromiseLike } from '../utils.lib' +import { createPipelineState, createTaskState } from './state.lib' +import { sleep } from './utils.lib' +import type { + PipelineEntry, + PipelineInstance, + PipelineOptions, + PipelineOutput, + PipelineResult, + PipelineRun, + PipelineState, + TaskState, +} from './state.lib' + +function ensurePending( + state: PipelineState, +): void { + if (!state.pending) { + state.pending = new Promise((res, rej) => { + state.resolvePending = res + state.rejectPending = rej + }) + } +} + +/** + * Stop an active run and cancel pending tasks. + * `true` if this call stopped the run, or `false` if it was already + * inactive. + * + * This function does NOT settle `pending`. + */ +function stopPipeline( + state: PipelineState, +): boolean { + if (!state.active) return false + + state.active = false + state.pendingTasks.forEach((cancel) => cancel()) + return true +} + +function cancelPipeline( + state: PipelineState, +): void { + const wasRunning = stopPipeline(state) + if (wasRunning) { + state.resolvePending?.() + } +} + +function failPipeline( + state: PipelineState, + error: unknown, +): void { + const wasRunning = stopPipeline(state) + if (wasRunning) { + state.rejectPending?.(error) + } +} + +function progressPipeline( + state: PipelineState, +): void { + if (!state.active) return + if (state.processingEntries) return + if (state.pendingTasks.size > 0) return + + if (state.nextEntryIndex === state.entries.length) { + state.active = false + state.resolvePending?.() + return + } + + try { + walkPipeline(state, [], true) + } catch (error) { + failPipeline(state, error) + } +} + +function isTaskPending( + task: TaskState, +): boolean { + return !task.settled && task.pipeline.active +} + +/** + * Releases a task's pending registration and current cancellation handle. + * + * @returns `true` if this call settled and released the task, or `false` if the + * task was already settled. + */ +function releaseTask( + task: TaskState, +): boolean { + if (task.settled) return false + task.settled = true + + task.pipeline.pendingTasks.delete(task.cancel) + if (task.instance.cancelCurrentExecution === task.cancel) { + task.instance.cancelCurrentExecution = null + } + return true +} + +function cancelTask( + task: TaskState, +): void { + if (!releaseTask(task)) return + task.controller.abort() + progressPipeline(task.pipeline) +} + +function acceptResult( + task: TaskState, + result: PipelineOutput, + syncBatch?: Array>, +): void { + if (!isTaskPending(task)) return + + const state = task.pipeline + const invalid = state.isInvalid?.(result) ?? false + if (!isTaskPending(task)) return + + state.hasInvalidResult ||= invalid + releaseTask(task) + + const pipelineResult: PipelineResult = { + instance: task.instance, + result, + } + + if (syncBatch) { + syncBatch.push(pipelineResult) + } else { + state.onResults?.([pipelineResult]) + } + progressPipeline(state) +} + +function executeTask( + task: TaskState, + syncBatch?: Array>, +): void { + if (!isTaskPending(task)) return + + const state = task.pipeline + const instance = task.instance + const result = instance.execute(state.input, task.controller.signal) + if (!isPromiseLike(result)) { + acceptResult(task, result, syncBatch) + return + } + + if (isTaskPending(task)) ensurePending(state) + + // We don't need to care about cancellation here. The handlers check for it. + void Promise.resolve(result).then( + (value) => { + try { + acceptResult(task, value) + } catch (error) { + failPipeline(state, error) + } + }, + (error: unknown) => { + if (isTaskPending(task)) { + failPipeline(state, error) + } + }, + ) +} + +function startTask( + state: PipelineState, + entry: PipelineEntry, + batch: Array>, +): void { + const task = createTaskState(state, entry, cancelTask) + const { instance, cancel } = task + + const previousCancel = instance.cancelCurrentExecution + instance.cancelCurrentExecution = cancel + + previousCancel?.() + state.pendingTasks.add(cancel) + + if (!isTaskPending(task)) return + + if (entry.debounceMs && entry.debounceMs > 0) { + ensurePending(state) + void sleep(entry.debounceMs, task.controller.signal) + .then(() => executeTask(task)) + .catch((error: unknown) => failPipeline(state, error)) + } else { + executeTask(task, batch) + } +} + +function walkPipeline( + state: PipelineState, + batch: Array>, + publish: boolean, +): void { + state.processingEntries = true + try { + while (state.active && state.nextEntryIndex < state.entries.length) { + const entry = state.entries[state.nextEntryIndex]! + + if (entry.bailIfInvalid) { + if (state.pendingTasks.size > 0) break + if (state.hasInvalidResult) { + state.nextEntryIndex++ + continue + } + } + + state.nextEntryIndex++ + startTask(state, entry, batch) + } + } finally { + state.processingEntries = false + } + + if (state.active && publish && batch.length > 0) state.onResults?.(batch) + progressPipeline(state) +} + +/** + * Starts the supplied entries while preserving synchronous result delivery. + * + * @returns A run handle containing the initial `syncResults` batch, a `pending` + * promise for deferred work (`null` for an entirely synchronous run), and a + * `cancel` function. Later batches go to `onResults` before `pending` resolves. + * Cancellation resolves `pending` without delivering cancelled results. A + * deferred failure rejects it after cancelling the remaining tasks. + * + * @throws Synchronous execution errors after cancelling work already started. + */ +export function runPipeline( + options: PipelineOptions, +): PipelineRun { + const state = createPipelineState(options) + + try { + walkPipeline(state, state.syncResults, false) + } catch (error) { + // The caller has not received the handle: resolve any internal pending promise + // during cleanup, then propagate the exception synchronously. + cancelPipeline(state) + throw error + } + + return { + syncResults: state.syncResults, + pending: state.pending, + cancel: () => cancelPipeline(state), + } +} diff --git a/packages/form-core/src/pipeline/state.lib.ts b/packages/form-core/src/pipeline/state.lib.ts new file mode 100644 index 000000000..96c56ae61 --- /dev/null +++ b/packages/form-core/src/pipeline/state.lib.ts @@ -0,0 +1,149 @@ +import type { BaseInstance } from './baseInstance.lib' + +export type PipelineInstance = BaseInstance< + TInput, + TResult +> + +type InputOf = + TInstance extends PipelineInstance ? TInput : never + +type ResultOf = + TInstance extends PipelineInstance ? TResult : never + +type TryGetFunctionType = + TInstance extends unknown ? (input: InputOf) => void : never + +// A shared input must satisfy every instance; one instance may accept a union. +export type PipelineInput = + TryGetFunctionType extends (input: infer TInput) => void + ? TInput + : never + +export type PipelineOutput = + ResultOf + +export interface PipelineEntry { + // this intersection might not be needed. Doesn't hurt, so oh well. + instance: TInstance & + PipelineInstance, PipelineOutput> + /** Delay in milliseconds before execution; omitted means no delay. */ + debounceMs?: number + /** Wait for earlier tasks, then skip this entry if the run is invalid. */ + bailIfInvalid?: boolean +} + +export interface PipelineResult { + /** Instance that produced this result, retaining its concrete type. */ + instance: TInstance + /** Normalized value returned by the instance's execute method. */ + result: PipelineOutput +} + +export interface PipelineOptions { + /** Input passed to every instance when its task executes. */ + input: PipelineInput + /** Ordered entries to process, including their debounce and bail settings. */ + entries: ReadonlyArray> + /** + * Classifies results for subsequent `bailIfInvalid` checks. + * + * @returns `true` when this result is invalid and later flagged entries should + * be skipped; `false` when it adds no invalidity to the run. + */ + isInvalid?: (result: PipelineOutput) => boolean + /** Receives result batches after the initial entry loop yields. */ + onResults?: (results: Array>) => void +} + +export interface PipelineRun { + /** Results collected before runPipeline returns; later results use onResults. */ + syncResults: Array> + /** + * Deferred run lifetime, or `null` for synchronous runs. Cancellation resolves + * it; failure rejects it. + */ + pending: Promise | null + /** Cancels this run and suppresses results from its unfinished tasks. */ + cancel: () => void +} + +export interface PipelineState< + TInstance extends PipelineInstance, +> extends PipelineOptions { + /** Initial synchronous batch; resumed loops publish separate batches. */ + syncResults: Array> + /** Handles that cancel this run's unfinished or debounced tasks. */ + pendingTasks: Set<() => void> + /** Index of the next entry, held in place while a bail check waits. */ + nextEntryIndex: number + /** Whether this run may still start tasks or accept results. */ + active: boolean + /** True while the entry loop runs; defers progress checks until it finishes. */ + processingEntries: boolean + /** True after an accepted invalid result; stays true for later bail checks. */ + hasInvalidResult: boolean + /** Wrapper promise exposed by the run handle; `null` until work is deferred. */ + pending: Promise | null + /** Resolves the wrapper promise; `undefined` until that promise is created. */ + resolvePending: (() => void) | undefined + /** Rejects pending with an error; `undefined` until the promise is created. */ + rejectPending: ((error: unknown) => void) | undefined +} + +export interface TaskState { + /** Execution bookkeeping for the run that owns this task. */ + pipeline: PipelineState + /** Instance executing this task; other runs can replace its current task. */ + instance: PipelineEntry['instance'] + /** Abort controller for this task; its signal is passed to execute. */ + controller: AbortController + /** True after a result, failure, or cancellation has ended this task. */ + settled: boolean + /** Cancels this exact task, leaving newer tasks on the same instance alone. */ + cancel: () => void +} + +/** + * Initializes run bookkeeping without executing any entries. + * + * @returns A new active run state with no collected results, no pending tasks, + * and no pending promise. + */ +export function createPipelineState( + options: PipelineOptions, +): PipelineState { + return { + ...options, + syncResults: [], + pendingTasks: new Set<() => void>(), + nextEntryIndex: 0, + active: true, + processingEntries: false, + hasInvalidResult: false, + pending: null, + resolvePending: undefined, + rejectPending: undefined, + } +} + +/** + * Creates task bookkeeping without registering or starting execution. + * + * @returns An unsettled task with a fresh abort controller and a cancellation + * handle bound to `onCancel` for this task. + */ +export function createTaskState( + pipeline: PipelineState, + entry: PipelineEntry, + onCancel: (task: TaskState) => void, +): TaskState { + const task: TaskState = { + pipeline, + instance: entry.instance, + controller: new AbortController(), + settled: false, + cancel: () => onCancel(task), + } + return task +} diff --git a/packages/form-core/src/pipeline/utils.lib.ts b/packages/form-core/src/pipeline/utils.lib.ts new file mode 100644 index 000000000..1716d70cd --- /dev/null +++ b/packages/form-core/src/pipeline/utils.lib.ts @@ -0,0 +1,38 @@ +import { isPromiseLike } from '../utils.lib' +import type { Awaitable } from '../types.public' + +/** + * Normalizes a direct value immediately, or a promised value when it resolves. + * + * @returns The direct normalization result for a synchronous input, or a promise + * of the normalized result for a promised input. + */ +export function mapResult( + value: TValue | PromiseLike, + normalize: (value: TValue) => TResult, +): Awaitable { + return isPromiseLike(value) + ? Promise.resolve(value).then(normalize) + : normalize(value) +} + +export function sleep( + milliseconds: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + + const finish = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', finish) + resolve() + } + + const timer = setTimeout(finish, milliseconds) + signal?.addEventListener('abort', finish, { once: true }) + }) +} diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 1662a45b4..ef85a2c49 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -10,10 +10,7 @@ import { shouldRunValidator, } from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { - AnyInternalFormApi, - InternalFormApi, -} from '../FormApi/FormApi.lib' +import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { From fa9a2db7af82027eb5f3d8949afe373ca5af7643 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:32:39 +0100 Subject: [PATCH 02/15] feat: allow runner to accept upstream invalidation --- .../form-core/src/pipeline/runPipeline.lib.ts | 20 ++++++++++++++++++- packages/form-core/src/pipeline/state.lib.ts | 12 +++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/pipeline/runPipeline.lib.ts b/packages/form-core/src/pipeline/runPipeline.lib.ts index b2d23f8d8..2155028b2 100644 --- a/packages/form-core/src/pipeline/runPipeline.lib.ts +++ b/packages/form-core/src/pipeline/runPipeline.lib.ts @@ -72,6 +72,8 @@ function progressPipeline( return } + if (state.hasPendingUpstream) return + try { walkPipeline(state, [], true) } catch (error) { @@ -209,9 +211,13 @@ function walkPipeline( const entry = state.entries[state.nextEntryIndex]! if (entry.bailIfInvalid) { - if (state.pendingTasks.size > 0) break + if (state.hasPendingUpstream || state.pendingTasks.size > 0) { + ensurePending(state) + break + } if (state.hasInvalidResult) { state.nextEntryIndex++ + state.onSkip?.(entry.instance) continue } } @@ -244,6 +250,18 @@ export function runPipeline( const state = createPipelineState(options) try { + if (isPromiseLike(state.upstreamInvalid)) { + state.hasPendingUpstream = true + void Promise.resolve(state.upstreamInvalid).then( + (invalid) => { + if (!state.active) return + state.hasPendingUpstream = false + state.hasInvalidResult ||= invalid + progressPipeline(state) + }, + (error: unknown) => failPipeline(state, error), + ) + } walkPipeline(state, state.syncResults, false) } catch (error) { // The caller has not received the handle: resolve any internal pending promise diff --git a/packages/form-core/src/pipeline/state.lib.ts b/packages/form-core/src/pipeline/state.lib.ts index 96c56ae61..7b24a5816 100644 --- a/packages/form-core/src/pipeline/state.lib.ts +++ b/packages/form-core/src/pipeline/state.lib.ts @@ -1,4 +1,5 @@ import type { BaseInstance } from './baseInstance.lib' +import type { Awaitable } from '../types.public' export type PipelineInstance = BaseInstance< TInput, @@ -45,6 +46,8 @@ export interface PipelineOptions { input: PipelineInput /** Ordered entries to process, including their debounce and bail settings. */ entries: ReadonlyArray> + /** Earlier validation's invalidity; awaited only at bail boundaries. */ + upstreamInvalid?: Awaitable /** * Classifies results for subsequent `bailIfInvalid` checks. * @@ -54,6 +57,8 @@ export interface PipelineOptions { isInvalid?: (result: PipelineOutput) => boolean /** Receives result batches after the initial entry loop yields. */ onResults?: (results: Array>) => void + /** Called synchronously when a bail decision skips an entry. */ + onSkip?: (instance: TInstance) => void } export interface PipelineRun { @@ -81,7 +86,9 @@ export interface PipelineState< active: boolean /** True while the entry loop runs; defers progress checks until it finishes. */ processingEntries: boolean - /** True after an accepted invalid result; stays true for later bail checks. */ + /** True until an upstream promise settles; separate from local tasks. */ + hasPendingUpstream: boolean + /** True after upstream or an accepted result is invalid; stays true. */ hasInvalidResult: boolean /** Wrapper promise exposed by the run handle; `null` until work is deferred. */ pending: Promise | null @@ -120,7 +127,8 @@ export function createPipelineState( nextEntryIndex: 0, active: true, processingEntries: false, - hasInvalidResult: false, + hasPendingUpstream: false, + hasInvalidResult: options.upstreamInvalid === true, pending: null, resolvePending: undefined, rejectPending: undefined, From 4d7bd6288cd962a3b3fc0ecedfe616d7708c9eb8 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:15:26 +0100 Subject: [PATCH 03/15] feat: add context builders for validation --- .../src/pipeline/validationContext.lib.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/form-core/src/pipeline/validationContext.lib.ts diff --git a/packages/form-core/src/pipeline/validationContext.lib.ts b/packages/form-core/src/pipeline/validationContext.lib.ts new file mode 100644 index 000000000..616b457c5 --- /dev/null +++ b/packages/form-core/src/pipeline/validationContext.lib.ts @@ -0,0 +1,143 @@ +import { createOutput } from '../validationOutput.lib' +import { createErrorMap } from '../validation/createErrorMap.lib' +import { parseIssuesFactory } from '../validation/parseIssues.lib' +import type { ClientSideEvents } from '../events.public' +import type { + FieldValidatorContext, + FormGroupValidatorContext, +} from '../validation.public' +import type { + FormValidatorClientContext, + FormValidatorPreprocessContext, + FormValidatorServerContext, +} from '../validation/formValidators.public' +import type { FieldErrorTarget } from '../validation/state.public' + +interface ValidatorContextDefinition { + run: unknown + fieldErrorTarget?: FieldErrorTarget +} + +interface FormClientInput { + event: ClientSideEvents + value: any + signal: AbortSignal + formApi: any + triggerFieldApi?: any +} + +interface FieldContextInput { + event: ClientSideEvents | 'mount' + value: any + signal: AbortSignal + formApi: any + fieldApi: any +} + +interface GroupContextInput { + event: ClientSideEvents | 'mount' + value: any + signal: AbortSignal + formApi: any + groupApi: any + triggerFieldApi?: any +} + +function createResultHelpers( + definition: ValidatorContextDefinition, + value: any, +) { + return { + createErrorMap, + parseIssues: parseIssuesFactory( + value, + definition.fieldErrorTarget ?? 'field', + ), + createOutput, + } +} + +/** + * This context is used for events where the formApi is present. + * + * This is NOT usable for `onSubmit` context. + */ +export function createFormClientValidatorContext( + definition: ValidatorContextDefinition, + input: FormClientInput, +): FormValidatorClientContext { + const { value } = input + return { + event: input.event, + value, + signal: input.signal, + formApi: input.formApi, + triggerFieldApi: input.triggerFieldApi, + ...createResultHelpers(definition, value), + } +} + +/** + * This context is used for mount, since formApi is not constructed yet. + */ +export function createFormMountValidatorContext( + definition: ValidatorContextDefinition, + input: { value: any }, +): FormValidatorPreprocessContext { + const { value } = input + return { + event: 'mount', + value, + ...createResultHelpers(definition, value), + } +} + +/** + * This context is used on the server, since formApi does not exist there. + */ +export function createFormServerValidatorContext( + definition: ValidatorContextDefinition, + input: { value: any }, +): FormValidatorServerContext { + const { value } = input + return { + event: 'server', + value, + ...createResultHelpers(definition, value), + } +} + +/** + * This context is used on the field. Always client-side. + */ +export function createFieldValidatorContext( + _definition: ValidatorContextDefinition, + input: FieldContextInput, +): FieldValidatorContext { + return { + event: input.event, + value: input.value, + signal: input.signal, + formApi: input.formApi, + fieldApi: input.fieldApi, + } +} + +/** + * This context is used on the form group. Always client-side. + */ +export function createGroupValidatorContext( + definition: ValidatorContextDefinition, + input: GroupContextInput, +): FormGroupValidatorContext { + const { value } = input + return { + event: input.event, + value, + signal: input.signal, + formApi: input.formApi, + groupApi: input.groupApi, + triggerFieldApi: input.triggerFieldApi, + ...createResultHelpers(definition, value), + } +} From 074097654c8bff6a4d270582f06ec6c6c4812f69 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:48:12 +0100 Subject: [PATCH 04/15] feat: set up scaffolding for standard schema and function --- .../src/pipeline/standardSchema.lib.ts | 51 ++++++++++++++++++ .../src/pipeline/validationExecution.lib.ts | 54 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packages/form-core/src/pipeline/standardSchema.lib.ts create mode 100644 packages/form-core/src/pipeline/validationExecution.lib.ts diff --git a/packages/form-core/src/pipeline/standardSchema.lib.ts b/packages/form-core/src/pipeline/standardSchema.lib.ts new file mode 100644 index 000000000..cd130571d --- /dev/null +++ b/packages/form-core/src/pipeline/standardSchema.lib.ts @@ -0,0 +1,51 @@ +import { createOutput } from '../validationOutput.lib' +import { parseIssuesFactory } from '../validation/parseIssues.lib' +import { mapResult } from './utils.lib' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from '../standardSchema.public' +import type { Awaitable } from '../types.public' +import type { ValidatorScope } from '../validation.public' +import type { + FieldErrorTarget, + ParsedStandardSchemaIssues, + ValidationOutput, +} from '../validation/state.public' + +export function isStandardSchema( + value: unknown, +): value is StandardSchemaV1 { + return ( + ((typeof value === 'object' && value !== null) || + typeof value === 'function') && + '~standard' in value + ) +} + +export function parseStandardSchema( + schema: StandardSchemaV1, + value: unknown, + { + scope, + fieldErrorTarget = 'field', + }: { scope: ValidatorScope; fieldErrorTarget?: FieldErrorTarget }, +): Awaitable< + | ValidationOutput + | ParsedStandardSchemaIssues + | ReadonlyArray +> { + return mapResult(schema['~standard'].validate(value), (result) => { + if (!result.issues) { + return createOutput(result.value) + } + + if (scope === 'field') { + return result.issues + } + + // This could sit outside mapResult, but it's a cheap function. Outside it'd + // create the closure on every result, even if successful. + return parseIssuesFactory(value, fieldErrorTarget)(result.issues) + }) +} diff --git a/packages/form-core/src/pipeline/validationExecution.lib.ts b/packages/form-core/src/pipeline/validationExecution.lib.ts new file mode 100644 index 000000000..c42892c2c --- /dev/null +++ b/packages/form-core/src/pipeline/validationExecution.lib.ts @@ -0,0 +1,54 @@ +import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' +import { isStandardSchema, parseStandardSchema } from './standardSchema.lib' +import { mapResult } from './utils.lib' +import type { StandardSchemaV1 } from '../standardSchema.public' +import type { Awaitable } from '../types.public' +import type { ValidatorScope } from '../validation.public' +import type { + FieldErrorTarget, + FormValidationResult, +} from '../validation/state.public' + +export interface ValidatorDefinition { + run: + StandardSchemaV1 | ((context: any) => Awaitable>) + fieldErrorTarget?: FieldErrorTarget +} + +export interface ValidatorExecutionResult { + result: FormValidationResult + output: unknown + hasOutput: boolean +} + +export function executeDefinition( + definition: ValidatorDefinition, + context: { value: any }, + { scope }: { scope: ValidatorScope }, +): Awaitable { + let result + if (isStandardSchema(definition.run)) { + result = parseStandardSchema(definition.run, context.value, { + scope, + fieldErrorTarget: definition.fieldErrorTarget, + }) + } else { + result = definition.run(context) + } + + return mapResult(result, normalizeValidatorResult) +} + +export function normalizeValidatorResult( + result: FormValidationResult, +): ValidatorExecutionResult { + if (isValidationOutput(result)) { + return { + result: null, + output: result[VALIDATION_OUTPUT], + hasOutput: true, + } + } + + return { result, output: null, hasOutput: false } +} From 9caa8b4805ff1661a0b314a6f5f6caef9e729817 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:09:23 +0100 Subject: [PATCH 05/15] feat: make basic validator executor --- ...cution.lib.ts => validatorExecutor.lib.ts} | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) rename packages/form-core/src/pipeline/{validationExecution.lib.ts => validatorExecutor.lib.ts} (55%) diff --git a/packages/form-core/src/pipeline/validationExecution.lib.ts b/packages/form-core/src/pipeline/validatorExecutor.lib.ts similarity index 55% rename from packages/form-core/src/pipeline/validationExecution.lib.ts rename to packages/form-core/src/pipeline/validatorExecutor.lib.ts index c42892c2c..58fb9f133 100644 --- a/packages/form-core/src/pipeline/validationExecution.lib.ts +++ b/packages/form-core/src/pipeline/validatorExecutor.lib.ts @@ -1,15 +1,16 @@ import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' +import { BaseInstance } from './baseInstance.lib' import { isStandardSchema, parseStandardSchema } from './standardSchema.lib' import { mapResult } from './utils.lib' -import type { StandardSchemaV1 } from '../standardSchema.public' import type { Awaitable } from '../types.public' import type { ValidatorScope } from '../validation.public' +import type { StandardSchemaV1 } from '../standardSchema.public' import type { FieldErrorTarget, FormValidationResult, } from '../validation/state.public' -export interface ValidatorDefinition { +interface ValidatorDefinition { run: StandardSchemaV1 | ((context: any) => Awaitable>) fieldErrorTarget?: FieldErrorTarget @@ -21,7 +22,7 @@ export interface ValidatorExecutionResult { hasOutput: boolean } -export function executeDefinition( +function executeDefinition( definition: ValidatorDefinition, context: { value: any }, { scope }: { scope: ValidatorScope }, @@ -39,7 +40,7 @@ export function executeDefinition( return mapResult(result, normalizeValidatorResult) } -export function normalizeValidatorResult( +function normalizeValidatorResult( result: FormValidationResult, ): ValidatorExecutionResult { if (isValidationOutput(result)) { @@ -52,3 +53,44 @@ export function normalizeValidatorResult( return { result, output: null, hasOutput: false } } + +interface ValidatorRunInput { + createContext: ( + definition: ValidatorDefinition, + signal: AbortSignal, + ) => { value: any } +} + +export class ValidatorExecutor< + TDefinition extends ValidatorDefinition = ValidatorDefinition, +> extends BaseInstance { + definition: TDefinition + readonly validatorScope: ValidatorScope + readonly index: number + + constructor({ + definition, + validatorScope, + index, + }: { + definition: TDefinition + validatorScope: ValidatorScope + index: number + }) { + super() + this.definition = definition + this.validatorScope = validatorScope + this.index = index + } + + execute( + input: ValidatorRunInput, + signal: AbortSignal, + ): Awaitable { + const definition = this.definition + const context = input.createContext(definition, signal) + return executeDefinition(definition, context, { + scope: this.validatorScope, + }) + } +} From 418ffdecac372d60cf1059634c88cd5ce1548827 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:42:22 +0100 Subject: [PATCH 06/15] refactor: add utils for trigger enabled state --- .../form-core/src/pipeline/runPipeline.lib.ts | 7 ++- packages/form-core/src/pipeline/state.lib.ts | 4 +- packages/form-core/src/pipeline/utils.lib.ts | 55 +++++++++++++++++++ .../form-core/src/validation/execution.lib.ts | 52 ++++++------------ 4 files changed, 77 insertions(+), 41 deletions(-) diff --git a/packages/form-core/src/pipeline/runPipeline.lib.ts b/packages/form-core/src/pipeline/runPipeline.lib.ts index 2155028b2..59aa79a20 100644 --- a/packages/form-core/src/pipeline/runPipeline.lib.ts +++ b/packages/form-core/src/pipeline/runPipeline.lib.ts @@ -136,7 +136,7 @@ function acceptResult( if (syncBatch) { syncBatch.push(pipelineResult) } else { - state.onResults?.([pipelineResult]) + state.onDeferredResults?.([pipelineResult]) } progressPipeline(state) } @@ -229,7 +229,8 @@ function walkPipeline( state.processingEntries = false } - if (state.active && publish && batch.length > 0) state.onResults?.(batch) + if (state.active && publish && batch.length > 0) + state.onDeferredResults?.(batch) progressPipeline(state) } @@ -238,7 +239,7 @@ function walkPipeline( * * @returns A run handle containing the initial `syncResults` batch, a `pending` * promise for deferred work (`null` for an entirely synchronous run), and a - * `cancel` function. Later batches go to `onResults` before `pending` resolves. + * `cancel` function. Later batches go to `onDeferredResults` before `pending` resolves. * Cancellation resolves `pending` without delivering cancelled results. A * deferred failure rejects it after cancelling the remaining tasks. * diff --git a/packages/form-core/src/pipeline/state.lib.ts b/packages/form-core/src/pipeline/state.lib.ts index 7b24a5816..1e6315d90 100644 --- a/packages/form-core/src/pipeline/state.lib.ts +++ b/packages/form-core/src/pipeline/state.lib.ts @@ -56,13 +56,13 @@ export interface PipelineOptions { */ isInvalid?: (result: PipelineOutput) => boolean /** Receives result batches after the initial entry loop yields. */ - onResults?: (results: Array>) => void + onDeferredResults?: (results: Array>) => void /** Called synchronously when a bail decision skips an entry. */ onSkip?: (instance: TInstance) => void } export interface PipelineRun { - /** Results collected before runPipeline returns; later results use onResults. */ + /** Results collected before runPipeline returns; later results use onDeferredResults. */ syncResults: Array> /** * Deferred run lifetime, or `null` for synchronous runs. Cancellation resolves diff --git a/packages/form-core/src/pipeline/utils.lib.ts b/packages/form-core/src/pipeline/utils.lib.ts index 1716d70cd..3cdd6c971 100644 --- a/packages/form-core/src/pipeline/utils.lib.ts +++ b/packages/form-core/src/pipeline/utils.lib.ts @@ -1,5 +1,60 @@ import { isPromiseLike } from '../utils.lib' import type { Awaitable } from '../types.public' +import type { ValidatorEvents, ValidatorTriggers } from '../events.public' + +type ValidatorCondition = boolean | ((context: any) => boolean) + +export type ValidatorTrigger = + ValidatorTriggers | { trigger: ValidatorTriggers; when?: ValidatorCondition } + +interface ValidatorSelectionContext { + event: ValidatorEvents + createPredicateContext?: () => any +} + +export interface SelectableValidator { + triggers?: ReadonlyArray + skipOnSubmit?: ValidatorCondition +} + +function isConditionEnabled( + condition: ValidatorCondition, + context: ValidatorSelectionContext, +): boolean { + if (typeof condition === 'boolean') return condition + if (context.event === 'server' || !context.createPredicateContext) { + return false + } + + return condition(context.createPredicateContext()) +} + +export function isValidationTriggerEnabled( + trigger: ValidatorTrigger, + context: ValidatorSelectionContext, +): boolean { + if (typeof trigger === 'string') return trigger === context.event + if (trigger.trigger !== context.event) return false + + const { when = true } = trigger + return isConditionEnabled(when, context) +} + +export function shouldRunValidator( + validator: SelectableValidator, + context: ValidatorSelectionContext, +): boolean { + if (context.event === 'submit') { + const { skipOnSubmit = false } = validator + return !isConditionEnabled(skipOnSubmit, context) + } + + return ( + validator.triggers?.some((trigger) => + isValidationTriggerEnabled(trigger, context), + ) ?? false + ) +} /** * Normalizes a direct value immediately, or a promised value when it resolves. diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index 1f4a3f8fe..1221d7a1e 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,5 +1,10 @@ import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' +import { + isValidationTriggerEnabled as isTriggerEnabled, + shouldRunValidator as isValidatorEnabled, +} from '../pipeline/utils.lib' +import type { SelectableValidator } from '../pipeline/utils.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { AnyInternalFormApi, @@ -157,14 +162,13 @@ interface PendingDebouncedCall { 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)) +function getSelectionContext(context: InputContext) { + return { + event: context.event, + createPredicateContext: isServerContext(context) + ? undefined + : () => getPredicateContext(context), + } } /** Resolves a static delay or evaluates its callback outside server context. */ @@ -188,17 +192,7 @@ 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) + return isTriggerEnabled(trigger, getSelectionContext(context)) } /** Selects a validator using server, submit, or configured event semantics. */ @@ -206,23 +200,9 @@ export function shouldRunValidator( validator: AnyPipelineValidator, context: InputContext, ): boolean { - if (isServerContext(context)) { - return ( - validator.triggers?.some((trigger) => - isValidationTriggerEnabled(trigger, context), - ) ?? false - ) - } - - const { skipOnSubmit = false } = validator - - if (context.event === 'submit') { - return !getEnabledState(skipOnSubmit, context) - } - - if (!validator.triggers) return false - return validator.triggers.some((signal) => - isValidationTriggerEnabled(signal, context), + return isValidatorEnabled( + validator as SelectableValidator, + getSelectionContext(context), ) } From f495a94456d34ba21c29d993c8ba9eda7550d195 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:42:39 +0100 Subject: [PATCH 07/15] feat: add server pipeline runner --- .../src/pipeline/serverValidation.lib.ts | 56 +++++++++++++++++++ .../src/pipeline/validatorExecutor.lib.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/form-core/src/pipeline/serverValidation.lib.ts diff --git a/packages/form-core/src/pipeline/serverValidation.lib.ts b/packages/form-core/src/pipeline/serverValidation.lib.ts new file mode 100644 index 000000000..88cd40c2f --- /dev/null +++ b/packages/form-core/src/pipeline/serverValidation.lib.ts @@ -0,0 +1,56 @@ +import { isErrorResult } from '../validation/errors.lib' +import { runPipeline } from './runPipeline.lib' +import { shouldRunValidator } from './utils.lib' +import { createFormServerValidatorContext } from './validationContext.lib' +import { ValidatorExecutor } from './validatorExecutor.lib' +import type { ValidatorTrigger } from './utils.lib' +import type { ValidatorDefinition } from './validatorExecutor.lib' +import type { PipelineEntry, PipelineResult, PipelineRun } from './state.lib' + +interface ServerValidator extends ValidatorDefinition { + triggers?: ReadonlyArray + bailIfInvalid?: boolean +} + +interface ServerValidationInput { + validators?: ReadonlyArray | null + value: any + onDeferredResults?: ( + results: Array>, + ) => void +} + +export function runServerValidators({ + validators, + value, + onDeferredResults, +}: ServerValidationInput): PipelineRun { + const entries: Array> = [] + + const list = validators ?? [] + + for (let i = 0; i < list.length; i++) { + const validator = list[i]! + + if (!shouldRunValidator(validator, { event: 'server' })) continue + + entries.push({ + instance: new ValidatorExecutor({ + definition: validator, + validatorScope: 'form', + index: i, + }), + bailIfInvalid: validator.bailIfInvalid, + }) + } + + return runPipeline({ + entries, + input: { + createContext: (definition) => + createFormServerValidatorContext(definition, { value }), + }, + isInvalid: ({ result }) => isErrorResult(result), + onDeferredResults, + }) +} diff --git a/packages/form-core/src/pipeline/validatorExecutor.lib.ts b/packages/form-core/src/pipeline/validatorExecutor.lib.ts index 58fb9f133..41f9dc24b 100644 --- a/packages/form-core/src/pipeline/validatorExecutor.lib.ts +++ b/packages/form-core/src/pipeline/validatorExecutor.lib.ts @@ -10,7 +10,7 @@ import type { FormValidationResult, } from '../validation/state.public' -interface ValidatorDefinition { +export interface ValidatorDefinition { run: StandardSchemaV1 | ((context: any) => Awaitable>) fieldErrorTarget?: FieldErrorTarget From 3dfc04f7cb527361e9cfa189328ce1bb6a7c72be Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:58:21 +0100 Subject: [PATCH 08/15] feat: add collection helper for promise resolution --- packages/form-core/src/pipeline/utils.lib.ts | 40 +++ .../src/validation/errorStorage.lib.ts | 237 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 packages/form-core/src/validation/errorStorage.lib.ts diff --git a/packages/form-core/src/pipeline/utils.lib.ts b/packages/form-core/src/pipeline/utils.lib.ts index 3cdd6c971..b18bd2099 100644 --- a/packages/form-core/src/pipeline/utils.lib.ts +++ b/packages/form-core/src/pipeline/utils.lib.ts @@ -1,6 +1,27 @@ import { isPromiseLike } from '../utils.lib' import type { Awaitable } from '../types.public' import type { ValidatorEvents, ValidatorTriggers } from '../events.public' +import type { PipelineResult } from './state.lib' +import type { ValidatorExecutor } from './validatorExecutor.lib' + +// Both submit and validators have these, but it'd be silly to invent a +// class they inherit from instead of just asserting this. +interface ValidationSourceOrder { + readonly scope: number + readonly index: number +} + +/** Returns a copy ordered by numeric scope priority, then original index. */ +export function sortValidationSources( + sources: Iterable, +): Array { + return Array.from(sources).sort((left, right) => { + if (left.scope === right.scope) { + return left.index - right.index + } + return left.scope - right.scope + }) +} type ValidatorCondition = boolean | ((context: any) => boolean) @@ -91,3 +112,22 @@ export function sleep( signal?.addEventListener('abort', finish, { once: true }) }) } + +/** Collects one run's accepted results in original validator-list order. */ +export class ValidationResultCollector< + TInstance extends ValidatorExecutor = ValidatorExecutor, +> { + private readonly accepted = new Map>() + + collect = (results: ReadonlyArray>): void => { + for (const result of results) { + this.accepted.set(result.instance, result) + } + } + + getResults = (): Array> => { + return Array.from(this.accepted.values()).sort( + (left, right) => left.instance.index - right.instance.index, + ) + } +} diff --git a/packages/form-core/src/validation/errorStorage.lib.ts b/packages/form-core/src/validation/errorStorage.lib.ts new file mode 100644 index 000000000..c675e3dd5 --- /dev/null +++ b/packages/form-core/src/validation/errorStorage.lib.ts @@ -0,0 +1,237 @@ +import { batch, createAtom } from '@tanstack/store' +import { evaluate } from '../utils.lib' +import { sortValidationSources } from '../pipeline/utils.lib' +import type { Atom, ReadonlyAtom } from '@tanstack/store' +import type { ValidatorEvents } from '../events.public' +import type { InternalValidationSourceScopePriority } from '../ValidationSourceInstance.lib' +import type { ValidationIssue } from './state.public' + +/** Storage and ordering state shared by executing and non-executing sources. */ +export interface ValidationErrorSource { + readonly scope: InternalValidationSourceScopePriority + readonly index: number + lastAppliedEvent: ValidatorEvents | null + errorTargets: Set | null +} + +/** A stable field or form-root identity, independent of its current path. */ +export interface ValidationErrorTarget { + readonly _errorStore: ValidationErrorStore +} + +type SourceErrorMap = Map> + +/** + * Takes a target off the source's list once the source has no errors there. + * Otherwise, replacing or clearing its errors would keep checking that target + * for no reason. Leaves the source's last applied event unchanged. + */ +function unlinkErrorTarget( + source: ValidationErrorSource, + target: ValidationErrorTarget, +): void { + source.errorTargets?.delete(target) + if (source.errorTargets?.size === 0) { + source.errorTargets = null + } +} + +/** + * Clear the errors at this target. + * @returns `true` if the target's map changed. + */ +function clearSourceErrorsAtTarget( + target: ValidationErrorTarget, + source: ValidationErrorSource, +): boolean { + return setSourceErrors(target, source, null) +} + +/** Owns the local error state independently of field metadata. */ +export class ValidationErrorStore { + readonly _target: ValidationErrorTarget + _cachedErrors: Array | null = null + + readonly atom: ReadonlyAtom + > | null> + + constructor(target: ValidationErrorTarget) { + this._target = target + this.atom = createAtom(null) + } + + /** Publishes a map prepared by the source operations in this module. */ + _setErrorMap(sourceErrors: SourceErrorMap | null): void { + this._cachedErrors = null + // We know the atom is mutable, but users shouldn't do that externally. + const writableAtom = this.atom as Atom + writableAtom.set(sourceErrors) + } + + get errors(): Array { + // Read even when cached so computed consumers retain their dependency. + const sourceErrors = this.atom.get() + + if (this._cachedErrors === null) { + this._cachedErrors = sourceErrors + ? sortValidationSources(sourceErrors.keys()).flatMap((source) => + sourceErrors.get(source)!, + ) + : [] + } + return this._cachedErrors + } + + get hasErrors(): boolean { + return this.atom.get() !== null + } + + /** + * Removes mount, server, and submit contributions permitted by the caller. + * Callers choose when to run cleanup and which sources can be cleared. + */ + clearStaleErrors( + canClearSource: (source: ValidationErrorSource) => boolean, + ): Set { + const errorMap = this.atom.get() + const affectedTargets = new Set() + if (!errorMap) return affectedTargets + + let next: SourceErrorMap | null = null + for (const source of errorMap.keys()) { + const event = source.lastAppliedEvent + if ( + (event !== 'mount' && event !== 'server' && event !== 'submit') || + !canClearSource(source) + ) { + continue + } + + next ??= new Map(errorMap) + next.delete(source) + } + if (!next) return affectedTargets + + batch(() => { + for (const source of errorMap.keys()) { + if (!next.has(source)) { + unlinkErrorTarget(source, this._target) + } + } + this._setErrorMap(next.size > 0 ? next : null) + affectedTargets.add(this._target) + }) + return affectedTargets + } + + /** Clears this target without changing contributions at other targets. */ + clearTargetErrors(): Set { + const errorMap = this.atom.get() + const affectedTargets = new Set() + if (!errorMap) return affectedTargets + + batch(() => { + for (const source of errorMap.keys()) { + unlinkErrorTarget(source, this._target) + } + this._setErrorMap(null) + affectedTargets.add(this._target) + }) + return affectedTargets + } +} + +/** + * Updates one map entry; callers maintain reverse links inside the same batch. + * + * @returns `true` if the target's map changed. + */ +function setSourceErrors( + target: ValidationErrorTarget, + source: ValidationErrorSource, + errors: Array | null, +): boolean { + const errorMap = target._errorStore.atom.get() + const previous = errorMap?.get(source) + if (errors ? previous && evaluate(previous, errors) : !previous) { + return false + } + + const next = new Map(errorMap) + if (errors) { + next.set(source, errors) + } else { + next.delete(source) + } + target._errorStore._setErrorMap(next.size > 0 ? next : null) + return true +} + +/** Replaces a source's complete contribution, including omitted old targets. */ +export function replaceSourceErrors( + source: ValidationErrorSource, + event: ValidatorEvents, + contributions: Iterable< + readonly [ValidationErrorTarget, ReadonlyArray] + >, +): Set { + const nextContributions = new Map< + ValidationErrorTarget, + Array + >() + for (const [target, errors] of contributions) { + if (errors.length === 0) continue + + const previous = nextContributions.get(target) + nextContributions.set( + target, + previous ? previous.concat(errors) : errors.slice(), + ) + } + + const affectedTargets = new Set() + batch(() => { + for (const target of source.errorTargets ?? []) { + if (!nextContributions.has(target)) { + const wasChanged = clearSourceErrorsAtTarget(target, source) + if (wasChanged) { + affectedTargets.add(target) + } + } + } + for (const [target, errors] of nextContributions) { + const wasChanged = setSourceErrors(target, source, errors) + if (wasChanged) { + affectedTargets.add(target) + } + } + + source.lastAppliedEvent = event + if (nextContributions.size > 0) { + source.errorTargets = new Set(nextContributions.keys()) + } else { + source.errorTargets = null + } + }) + return affectedTargets +} + +/** Clears every contribution of a removed source. */ +export function removeSourceErrors( + source: ValidationErrorSource, +): Set { + const affectedTargets = new Set() + batch(() => { + for (const target of source.errorTargets ?? []) { + const wasChanged = clearSourceErrorsAtTarget(target, source) + if (wasChanged) { + affectedTargets.add(target) + } + } + source.errorTargets = null + source.lastAppliedEvent = null + }) + return affectedTargets +} From 0b7e215b2601b46e3dfa9a10b99a8d1d82ebdf26 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:08:26 +0100 Subject: [PATCH 09/15] feat: add validation tracker --- .../src/pipeline/validatorExecutor.lib.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/form-core/src/pipeline/validatorExecutor.lib.ts b/packages/form-core/src/pipeline/validatorExecutor.lib.ts index 41f9dc24b..7e9cc7604 100644 --- a/packages/form-core/src/pipeline/validatorExecutor.lib.ts +++ b/packages/form-core/src/pipeline/validatorExecutor.lib.ts @@ -9,6 +9,7 @@ import type { FieldErrorTarget, FormValidationResult, } from '../validation/state.public' +import type { PipelineInstance, PipelineRun } from './state.lib' export interface ValidatorDefinition { run: @@ -94,3 +95,37 @@ export class ValidatorExecutor< }) } } + +type ValidationRun = Pick, 'pending' | 'cancel'> + +/** + * Track validation runs until they seettle or are cancelled. + * + * Standalone server validation awaits a single run and does not need this tracker. + */ +export class ValidationRunTracker { + private readonly runs = new Set() + + get hasPending(): boolean { + return this.runs.size > 0 + } + + track = (run: ValidationRun): void => { + if (!run.pending || this.runs.has(run)) return + + this.runs.add(run) + const release = () => { + this.runs.delete(run) + } + void run.pending.then(release, release) + } + + cancelAll = (): void => { + const runs = Array.from(this.runs) + this.runs.clear() + + for (const run of runs) { + run.cancel() + } + } +} From 1e3f5f87025084c18d58f94e16c2f3ebbcea3364 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:31:47 +0100 Subject: [PATCH 10/15] refactor: inject new functions into existing work --- .../src/ValidationSourceInstance.lib.ts | 27 ++++---- .../form-core/src/ValidatorInstance.lib.ts | 68 +++++++++++++++---- .../src/pipeline/ValidationInstance.lib.ts | 50 ++++++++++++++ 3 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 packages/form-core/src/pipeline/ValidationInstance.lib.ts diff --git a/packages/form-core/src/ValidationSourceInstance.lib.ts b/packages/form-core/src/ValidationSourceInstance.lib.ts index 5be70c527..ae114d461 100644 --- a/packages/form-core/src/ValidationSourceInstance.lib.ts +++ b/packages/form-core/src/ValidationSourceInstance.lib.ts @@ -1,4 +1,9 @@ +import { + addValidationErrorTarget, + deleteValidationErrorTarget, +} from './pipeline/ValidationInstance.lib' import type { ValidatorScope } from './validation.public' +import type { ValidationInstance } from './pipeline/ValidationInstance.lib' /** Internal validation sources ordered by their contribution to derived errors. */ export const validationSourceScopes = { @@ -19,11 +24,13 @@ export interface InternalValidationSourceInstanceOptions { index?: number } -export type AnyInternalValidationSourceInstance = - InternalValidationSourceInstance +export type AnyInternalValidationSourceInstance = ValidationInstance /** Stable identity and ordering metadata for one internal validation source. */ -export class InternalValidationSourceInstance { +export class InternalValidationSourceInstance< + TOwner, + TErrorTarget = unknown, +> implements ValidationInstance { /** The validation boundary that owns this source. */ readonly owner: TOwner /** Numeric priority used to order errors across validation scopes. */ @@ -48,22 +55,12 @@ export class InternalValidationSourceInstance { /** 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) + addValidationErrorTarget(this, 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 - } + deleteValidationErrorTarget(this, errorTarget) } /** Clears transient state while preserving this source's stable identity. */ diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts index 807bba073..07d1bdcde 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -1,5 +1,10 @@ import { LiteDebouncer } from '@tanstack/pacer-lite' -import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib' +import { validationSourceScopes } from './ValidationSourceInstance.lib' +import { + addValidationErrorTarget, + deleteValidationErrorTarget, +} from './pipeline/ValidationInstance.lib' +import { ValidatorExecutor } from './pipeline/validatorExecutor.lib' import type { FieldValidator, FormGroupValidator, @@ -7,6 +12,10 @@ import type { } from './validation.public' import type { FormValidator } from './validation/formValidators.public' import type { FieldErrorTarget } from './validation/state.public' +import type { + ValidationInstance, + ValidationInstanceScopePriority, +} from './pipeline/ValidationInstance.lib' type GetDefinition = { form: FormValidator @@ -99,9 +108,18 @@ export class InternalValidatorInstance< TOutput = unknown, TDebouncedFn extends ValidatorInstanceDebouncedFn = ValidatorInstanceDebouncedFn, -> extends InternalValidationSourceInstance { - /** The current validator definition associated with this stable instance. */ - definition: GetDefinition +> + extends ValidatorExecutor> + implements ValidationInstance +{ + /** The validation boundary that owns this source. */ + readonly owner: TOwner + /** Numeric priority used to order errors across validation scopes. */ + readonly scope: ValidationInstanceScopePriority + /** Targets currently receiving errors routed from this source. */ + errorTargets: Set | null = null + /** Whether this source has been permanently disposed. */ + disposed = false /** 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. */ @@ -136,16 +154,27 @@ export class InternalValidatorInstance< definition, owner, scope, - index, + index = 0, }: InternalValidatorInstanceOptions) { - super({ owner, scope, index }) - this.definition = definition + super({ definition, validatorScope: scope, index }) + this.owner = owner + this.scope = validationSourceScopes[scope] this.fieldErrorTarget = 'field' if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { this.fieldErrorTarget = definition.fieldErrorTarget } } + /** Records a target receiving errors from this source. */ + addErrorTarget(errorTarget: TErrorTarget): void { + addValidationErrorTarget(this, errorTarget) + } + + /** Stops tracking a target that no longer receives errors from this source. */ + deleteErrorTarget(errorTarget: TErrorTarget): void { + deleteValidationErrorTarget(this, errorTarget) + } + /** * Replaces the definition while preserving this instance and its runtime state. * @@ -279,10 +308,9 @@ export class InternalValidatorInstance< } /** - * Aborts the active execution and cancels any pending debounced execution. + * Cancels the runner task, aborts active execution, and cancels debounced work. * - * Both execution resources are released. The operation is ignored after - * disposal. + * Execution resources are released. The operation is ignored after disposal. */ cancelExecution(): void { if (this.disposed) return @@ -297,17 +325,30 @@ export class InternalValidatorInstance< * preserved. The operation is ignored after disposal. */ resetRuntime(): void { - super.resetRuntime() + 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 validator-specific runtime state during reset or disposal. */ - protected override _resetRuntime(): void { + protected _resetRuntime(): void { this._cancelExecution() this._clearOutput() } /** Releases validator-only collections and mount state during disposal. */ - protected override _disposeRuntime(): void { + protected _disposeRuntime(): void { this._resetRuntime() this.resolvedWatchFields = null this.didRunOnMount = false @@ -315,6 +356,7 @@ export class InternalValidatorInstance< /** Cancels and releases execution resources without checking disposal state. */ private _cancelExecution(): void { + this.cancelCurrentExecution?.() this.abortController?.abort() this.abortController = null this.debouncer?.cancel() diff --git a/packages/form-core/src/pipeline/ValidationInstance.lib.ts b/packages/form-core/src/pipeline/ValidationInstance.lib.ts new file mode 100644 index 000000000..e578fa3e4 --- /dev/null +++ b/packages/form-core/src/pipeline/ValidationInstance.lib.ts @@ -0,0 +1,50 @@ +/** Error ordering priority: field, group, form, then onSubmit. */ +export type ValidationInstanceScopePriority = 0 | 1 | 2 | 3 + +/** Shared contract for executing and non-executing validation sources. */ +export interface ValidationInstance { + /** The field, group, or form that owns this source. */ + readonly owner: TOwner + /** Numeric priority used to order errors across validation scopes. */ + readonly scope: ValidationInstanceScopePriority + /** Original position within the owning scope, used to order its errors. */ + readonly index: number + /** Targets currently storing this source's errors, or `null` when empty. */ + errorTargets: Set | null + /** Whether this source has been permanently disposed. */ + disposed: boolean + /** Records a target receiving errors from this source. */ + addErrorTarget(errorTarget: TErrorTarget): void + /** Removes a target reference once it no longer stores this source's errors. */ + deleteErrorTarget(errorTarget: TErrorTarget): void + /** Releases transient state and target references while preserving identity. */ + resetRuntime(): void + /** Permanently releases source state, calling `onBeforeDispose` before cleanup. */ + dispose(onBeforeDispose?: (instance: this) => void): void +} + +/** Records an error target unless the validation instance has been disposed. */ +export function addValidationErrorTarget( + instance: ValidationInstance, + errorTarget: TErrorTarget, +): void { + if (instance.disposed) return + + if (!instance.errorTargets) { + instance.errorTargets = new Set() + } + instance.errorTargets.add(errorTarget) +} + +/** Removes a target unless disposed and resets an empty target set to `null`. */ +export function deleteValidationErrorTarget( + instance: ValidationInstance, + errorTarget: TErrorTarget, +): void { + if (instance.disposed) return + + instance.errorTargets?.delete(errorTarget) + if (instance.errorTargets?.size === 0) { + instance.errorTargets = null + } +} From 9019c692aac99160730ec1f4e6d53fc798d1b041 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:06:37 +0100 Subject: [PATCH 11/15] refactor: make SSR use new system --- .../form-core/src/pipeline/runPipeline.lib.ts | 6 +- packages/form-core/src/pipeline/state.lib.ts | 2 +- packages/form-core/src/pipeline/utils.lib.ts | 27 +++++ packages/form-core/src/ssr.lib.ts | 106 +++++------------- 4 files changed, 59 insertions(+), 82 deletions(-) diff --git a/packages/form-core/src/pipeline/runPipeline.lib.ts b/packages/form-core/src/pipeline/runPipeline.lib.ts index 59aa79a20..0be769895 100644 --- a/packages/form-core/src/pipeline/runPipeline.lib.ts +++ b/packages/form-core/src/pipeline/runPipeline.lib.ts @@ -116,13 +116,13 @@ function cancelTask( function acceptResult( task: TaskState, - result: PipelineOutput, + value: PipelineOutput, syncBatch?: Array>, ): void { if (!isTaskPending(task)) return const state = task.pipeline - const invalid = state.isInvalid?.(result) ?? false + const invalid = state.isInvalid?.(value) ?? false if (!isTaskPending(task)) return state.hasInvalidResult ||= invalid @@ -130,7 +130,7 @@ function acceptResult( const pipelineResult: PipelineResult = { instance: task.instance, - result, + value, } if (syncBatch) { diff --git a/packages/form-core/src/pipeline/state.lib.ts b/packages/form-core/src/pipeline/state.lib.ts index 1e6315d90..31f8af819 100644 --- a/packages/form-core/src/pipeline/state.lib.ts +++ b/packages/form-core/src/pipeline/state.lib.ts @@ -38,7 +38,7 @@ export interface PipelineResult { /** Instance that produced this result, retaining its concrete type. */ instance: TInstance /** Normalized value returned by the instance's execute method. */ - result: PipelineOutput + value: PipelineOutput } export interface PipelineOptions { diff --git a/packages/form-core/src/pipeline/utils.lib.ts b/packages/form-core/src/pipeline/utils.lib.ts index b18bd2099..507690827 100644 --- a/packages/form-core/src/pipeline/utils.lib.ts +++ b/packages/form-core/src/pipeline/utils.lib.ts @@ -1,4 +1,5 @@ import { isPromiseLike } from '../utils.lib' +import { isErrorResult } from '../validation/errors.lib' import type { Awaitable } from '../types.public' import type { ValidatorEvents, ValidatorTriggers } from '../events.public' import type { PipelineResult } from './state.lib' @@ -130,4 +131,30 @@ export class ValidationResultCollector< (left, right) => left.instance.index - right.instance.index, ) } + + /** Checks whether this run's currently accepted results contain validation errors. */ + hasErrors = (): boolean => { + for (const { value } of this.accepted.values()) { + if (isErrorResult(value.result)) return true + } + + return false + } + + /** + * Returns outputs at their original indexes, leaving missing outputs `undefined`. + * + * @param validatorCount - Full validator-list length, including skipped validators. + */ + getOutputs = (validatorCount: number): Array => { + const outputs = new Array(validatorCount).fill(undefined) + + for (const { instance, value } of this.accepted.values()) { + if (value.hasOutput) { + outputs[instance.index] = value.output + } + } + + return outputs + } } diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index c09519e5e..f5ae356c1 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -1,17 +1,13 @@ import { batch } from '@tanstack/store' -import { createOutput } from './validationOutput.lib' import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' import { evaluate } from './utils.lib' -import { runValidatorPipeline } from './validation' -import { createErrorMap } from './validation/createErrorMap.lib' -import { parseIssuesFactory } from './validation/parseIssues.lib' +import { runServerValidators } from './pipeline/serverValidation.lib' +import { ValidationResultCollector } from './pipeline/utils.lib' import { devtools } from './devtoolsBridge.lib' -import { reconcileValidatorInstances } from './ValidatorInstance.lib' import type { FormErrorMeta } from './FormApi/formState.lib' import type { AnyInternalFormApi, InternalFormApi } from './FormApi/FormApi.lib' import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { FormValidationResult } from './validation/state.public' import type { ServerFormState, ServerValidateResult, @@ -120,82 +116,36 @@ export async function validateServerValues< >, values: TFormData, ): Promise> { - const pipeline = options.validators - - if (!pipeline || pipeline.length === 0) { - return { - success: true, - values, - validatorOutputs: [] as never, - } - } - - const validatorInstances = reconcileValidatorInstances({ - definitions: pipeline, - instances: null, - owner: options, - scope: 'form', + const validators = options.validators + const validatorCount = validators?.length ?? 0 + const collector = new ValidationResultCollector() + const run = runServerValidators({ + validators, + value: values, + onDeferredResults: collector.collect, }) + collector.collect(run.syncResults) + await run.pending - try { - const pipelineResult = await runValidatorPipeline< - FormValidationResult - >({ - pipeline: validatorInstances ?? [], - context: { - scope: 'server', - event: 'server', - formApi: undefined, + if (collector.hasErrors()) { + return { + success: false, + serverState: { + values, + validationResults: collector + .getResults() + .map(({ instance, value }) => ({ + validatorIndex: instance.index, + ...value, + })), + submissionAttempts: 1, }, - hasFailedBefore: false, - getContext: (ctx, instance) => ({ - event: 'server' as const, - signal: ctx.signal, - formApi: undefined, - value: values, - createErrorMap, - parseIssues: parseIssuesFactory(values, instance.fieldErrorTarget), - createOutput, - }), - }) - - if (pipelineResult.thrownError !== null) { - throw pipelineResult.thrownError - } - - const validatorOutputs = - validatorInstances?.map((instance) => { - const result = pipelineResult.results.find( - (r) => r.validatorInstance === instance, - ) - return result?.hasOutput ? result.output : undefined - }) ?? [] - - const validationResults = pipelineResult.results.map((result) => ({ - validatorIndex: - validatorInstances?.indexOf(result.validatorInstance) ?? -1, - result: result.result, - output: result.output, - hasOutput: result.hasOutput, - })) - - if (pipelineResult.hasErrors) { - return { - success: false, - serverState: { - values, - validationResults, - submissionAttempts: 1, - }, - } } + } - return { - success: true, - values, - validatorOutputs: validatorOutputs as never, - } - } finally { - validatorInstances?.forEach((instance) => instance.dispose()) + return { + success: true, + values, + validatorOutputs: collector.getOutputs(validatorCount) as any, } } From f8c64a063815388f8e58f12c9e32f95027b70d08 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:16:43 +0100 Subject: [PATCH 12/15] refactor: connect new implementation Sorry about the size of this one. This also breaks some unit tests, because there's no way to commit both at once effectively. Check the next commit. --- packages/form-core/package.json | 1 - .../form-core/src/FieldApi/FieldApi.lib.ts | 938 +++-------------- .../form-core/src/FieldApi/FieldApi.public.ts | 14 +- .../src/FieldApi/RootFieldApi.lib.ts | 54 +- .../form-core/src/FieldApi/fieldEvents.lib.ts | 290 ++++++ .../src/FieldApi/fieldOptions.lib.ts | 197 ++++ .../form-core/src/FieldApi/fieldState.lib.ts | 104 +- .../src/FieldApi/fieldTraversal.lib.ts | 104 +- .../form-core/src/FieldApi/fieldTree.lib.ts | 371 +------ .../src/FieldApi/linked-fields.lib.ts | 297 ++---- .../src/FieldGroup/FieldGroupApi.lib.ts | 8 +- .../src/FieldGroup/FieldGroupApi.public.ts | 4 +- packages/form-core/src/FormApi/FormApi.lib.ts | 977 +++++------------- .../form-core/src/FormApi/FormApi.public.ts | 45 +- .../src/FormApi/array-methods.lib.ts | 249 +++-- .../src/FormApi/errorVisibility.public.ts | 48 +- .../form-core/src/FormApi/formOptions.lib.ts | 135 +++ .../src/FormApi/formOptions.public.ts | 12 +- .../src/FormApi/formServerState.lib.ts | 80 ++ .../src/FormApi/formServerState.public.ts | 11 +- .../form-core/src/FormApi/formState.lib.ts | 173 +--- .../src/FormApi/formSubmit.public.ts | 14 +- .../form-core/src/FormApi/handleSubmit.lib.ts | 268 +++-- .../src/FormApi/initialFormState.lib.ts | 88 ++ .../src/FormApi/initializeForm.lib.ts | 64 ++ .../src/FormGroupApi/FormGroupApi.lib.ts | 861 +++++---------- .../src/FormGroupApi/FormGroupApi.public.ts | 21 +- .../src/FormGroupApi/formGroupState.lib.ts | 152 +++ .../src/FormGroupApi/handleSubmit.lib.ts | 96 ++ .../form-core/src/ListenerInstance.lib.ts | 189 ---- .../src/ValidationSourceInstance.lib.ts | 102 -- .../form-core/src/ValidatorInstance.lib.ts | 450 -------- .../form-core/src/defaultOptions.public.ts | 9 +- packages/form-core/src/devtoolsBridge.lib.ts | 23 +- packages/form-core/src/index.ts | 13 +- packages/form-core/src/internals.ts | 19 +- packages/form-core/src/listeners.lib.ts | 229 ---- .../src/pipeline/ValidationInstance.lib.ts | 50 - .../src/{ => pipeline}/events.public.ts | 0 .../listeners/ListenerInstance.lib.ts | 123 +++ .../listeners}/formListeners.public.ts | 16 +- .../pipeline/listeners/listenerContext.lib.ts | 68 ++ .../src/pipeline/listeners/listeners.lib.ts | 135 +++ .../listeners}/listeners.public.ts | 41 +- ...nstance.lib.ts => pipelineInstance.lib.ts} | 2 +- .../form-core/src/pipeline/runPipeline.lib.ts | 152 ++- .../form-core/src/pipeline/runTracker.lib.ts | 30 + .../form-core/src/pipeline/selection.lib.ts | 54 + .../src/pipeline/serverValidation.lib.ts | 56 - packages/form-core/src/pipeline/state.lib.ts | 47 +- packages/form-core/src/pipeline/utils.lib.ts | 122 --- .../validation/ValidatorInstance.lib.ts | 285 +++++ .../validation/clientValidation.lib.ts | 221 ++++ .../validation/formValidators.public.ts | 20 +- .../validation/ownerValidation.lib.ts | 347 +++++++ .../validation/resultCollection.lib.ts | 48 + .../src/pipeline/validation/selection.lib.ts | 46 + .../validation/serverValidation.lib.ts | 113 ++ .../{ => validation}/standardSchema.lib.ts | 20 +- .../validation}/validation.public.ts | 269 +---- .../{ => validation}/validationContext.lib.ts | 18 +- .../src/pipeline/validatorExecutor.lib.ts | 131 --- packages/form-core/src/ssr.lib.ts | 151 --- .../form-core/src/submissionAttempt.lib.ts | 187 ++++ packages/form-core/src/types.lib.ts | 13 +- packages/form-core/src/utils.lib.ts | 30 +- .../src/validation/errorStorage.lib.ts | 136 ++- .../form-core/src/validation/errors.lib.ts | 264 ----- .../form-core/src/validation/execution.lib.ts | 444 -------- packages/form-core/src/validation/index.ts | 34 - .../form-core/src/validation/mount.lib.ts | 356 ------- .../src/validation/parseIssues.lib.ts | 24 - .../form-core/src/validation/pipeline.lib.ts | 344 ------ .../{ => results}/createErrorMap.lib.ts | 4 +- .../src/validation/results/errors.lib.ts | 124 +++ .../results/parseIssues.lib.ts} | 66 +- .../src/validation/results/results.public.ts | 189 ++++ .../results}/standardSchema.public.ts | 0 .../results}/validationOutput.lib.ts | 5 +- .../form-core/src/validation/state.public.ts | 293 +++--- .../src/bridge/fields/detailSnapshot.ts | 184 +--- .../schemaErrorsOnUnmountedDescendants.ts | 8 +- .../fieldDebug/validatorsWithoutTriggers.ts | 4 +- .../form-devtools/src/bridge/fields/index.ts | 66 +- packages/form-devtools/tests/testUtils.ts | 22 +- packages/react-form-start/src/utils.ts | 17 +- pnpm-lock.yaml | 3 - 87 files changed, 5127 insertions(+), 6965 deletions(-) create mode 100644 packages/form-core/src/FieldApi/fieldEvents.lib.ts create mode 100644 packages/form-core/src/FieldApi/fieldOptions.lib.ts create mode 100644 packages/form-core/src/FormApi/formOptions.lib.ts create mode 100644 packages/form-core/src/FormApi/formServerState.lib.ts create mode 100644 packages/form-core/src/FormApi/initialFormState.lib.ts create mode 100644 packages/form-core/src/FormApi/initializeForm.lib.ts create mode 100644 packages/form-core/src/FormGroupApi/formGroupState.lib.ts create mode 100644 packages/form-core/src/FormGroupApi/handleSubmit.lib.ts delete mode 100644 packages/form-core/src/ListenerInstance.lib.ts delete mode 100644 packages/form-core/src/ValidationSourceInstance.lib.ts delete mode 100644 packages/form-core/src/ValidatorInstance.lib.ts delete mode 100644 packages/form-core/src/listeners.lib.ts delete mode 100644 packages/form-core/src/pipeline/ValidationInstance.lib.ts rename packages/form-core/src/{ => pipeline}/events.public.ts (100%) create mode 100644 packages/form-core/src/pipeline/listeners/ListenerInstance.lib.ts rename packages/form-core/src/{FormApi => pipeline/listeners}/formListeners.public.ts (60%) create mode 100644 packages/form-core/src/pipeline/listeners/listenerContext.lib.ts create mode 100644 packages/form-core/src/pipeline/listeners/listeners.lib.ts rename packages/form-core/src/{ => pipeline/listeners}/listeners.public.ts (87%) rename packages/form-core/src/pipeline/{baseInstance.lib.ts => pipelineInstance.lib.ts} (88%) create mode 100644 packages/form-core/src/pipeline/runTracker.lib.ts create mode 100644 packages/form-core/src/pipeline/selection.lib.ts delete mode 100644 packages/form-core/src/pipeline/serverValidation.lib.ts create mode 100644 packages/form-core/src/pipeline/validation/ValidatorInstance.lib.ts create mode 100644 packages/form-core/src/pipeline/validation/clientValidation.lib.ts rename packages/form-core/src/{ => pipeline}/validation/formValidators.public.ts (98%) create mode 100644 packages/form-core/src/pipeline/validation/ownerValidation.lib.ts create mode 100644 packages/form-core/src/pipeline/validation/resultCollection.lib.ts create mode 100644 packages/form-core/src/pipeline/validation/selection.lib.ts create mode 100644 packages/form-core/src/pipeline/validation/serverValidation.lib.ts rename packages/form-core/src/pipeline/{ => validation}/standardSchema.lib.ts (66%) rename packages/form-core/src/{ => pipeline/validation}/validation.public.ts (73%) rename packages/form-core/src/pipeline/{ => validation}/validationContext.lib.ts (89%) delete mode 100644 packages/form-core/src/pipeline/validatorExecutor.lib.ts delete mode 100644 packages/form-core/src/ssr.lib.ts create mode 100644 packages/form-core/src/submissionAttempt.lib.ts delete mode 100644 packages/form-core/src/validation/errors.lib.ts delete mode 100644 packages/form-core/src/validation/execution.lib.ts delete mode 100644 packages/form-core/src/validation/index.ts delete mode 100644 packages/form-core/src/validation/mount.lib.ts delete mode 100644 packages/form-core/src/validation/parseIssues.lib.ts delete mode 100644 packages/form-core/src/validation/pipeline.lib.ts rename packages/form-core/src/validation/{ => results}/createErrorMap.lib.ts (94%) create mode 100644 packages/form-core/src/validation/results/errors.lib.ts rename packages/form-core/src/{standardSchema.lib.ts => validation/results/parseIssues.lib.ts} (51%) create mode 100644 packages/form-core/src/validation/results/results.public.ts rename packages/form-core/src/{ => validation/results}/standardSchema.public.ts (100%) rename packages/form-core/src/{ => validation/results}/validationOutput.lib.ts (86%) diff --git a/packages/form-core/package.json b/packages/form-core/package.json index 9df779251..26c6e22f7 100644 --- a/packages/form-core/package.json +++ b/packages/form-core/package.json @@ -42,7 +42,6 @@ "dist/" ], "dependencies": { - "@tanstack/pacer-lite": "^0.2.2", "@tanstack/store": "^0.11.1" }, "devDependencies": { diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 8d1ae20fd..f831011c8 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1,85 +1,83 @@ import { batch, createAtom } from '@tanstack/store' -import { callUpdater, evaluate, getBy } from '../utils.lib' -import { - clearValidationSourceErrorsFromEvent, - isValidationTriggerEnabled, - parseValidationResult, - runFieldMountValidatorPipeline, - runFieldValidatorPipeline, - setValidationSourceError, -} from '../validation' -import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' -import { reconcileValidatorInstances } from '../ValidatorInstance.lib' -import { reconcileListenerInstances } from '../ListenerInstance.lib' -import { resolveDefaultOptions } from '../defaultOptions.lib' import { - attachWatchingListenerField, - attachWatchingValidatorField, - detachWatchingListenerField, - detachWatchingValidatorField, - reconcileWatchedListenerFields, - reconcileWatchedValidatorFields, -} from './linked-fields.lib' + ValidationErrorStore, + removeSourceErrors, + replaceSourceErrors, + validationSourceScopes, +} from '../validation/errorStorage.lib' +import { ValidationRunTracker } from '../pipeline/runTracker.lib' +import { callUpdater, evaluate, getBy } from '../utils.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' +import { startFieldValidation } from '../pipeline/validation/ownerValidation.lib' +import { parseValidationResult } from '../validation/results/errors.lib' import { defaultInternalBaseFieldMeta, deriveFromBaseFieldMeta, - getChildContributionStates, getFieldSnapshot, + getSubfieldsMetaFlags, } from './fieldState.lib' +import { initializeFieldOptions, updateFieldOptions } from './fieldOptions.lib' +import { + handleFieldEvent, + notifyFieldListener, + notifyFieldValidator, + triggerValidationFromField, +} from './fieldEvents.lib' import { killField, moveFieldToSegment, pruneFieldIfUnused, - updateChildContributionCount, + updateChildMetaCounts, } from './fieldTree.lib' import { visitFieldAndAncestors } from './fieldTraversal.lib' +import type { FieldApi, FieldApiOptions } from './FieldApi.public' +import type { + FieldValidator, + FieldValidators, +} from '../pipeline/validation/validation.public' import type { InternalRootFieldApi } from './RootFieldApi.lib' +import type { NameSegment } from '../utils.lib' +import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' +import type { FieldListenerTriggers } from '../pipeline/listeners/listeners.public' +import type { + NotifyFieldListenerOptions, + NotifyFieldValidatorOptions, +} from './fieldEvents.lib' import type { DeepKeys, DeepValue, TryGetArrayElementType, } from '../deep-keys.public' import type { - AnyFieldListener, - FieldListenerTriggers, -} from '../listeners.public' -import type { NameSegment, NameSegments } from '../utils.lib' -import type { - FieldValidatorPipelineResult, - PipelineResult, -} 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 { - InternalValidatorInstance, - InternalValidatorInstances, -} from '../ValidatorInstance.lib' -import type { - InternalListenerInstance, - InternalListenerInstances, -} from '../ListenerInstance.lib' -import type { FieldApi, FieldApiOptions } from './FieldApi.public' -import type { - FieldErrors, - FieldValidateResult, - FieldValidator, - FieldValidators, -} from '../validation.public' -import type { - ChildContributionStates, DerivedMetaMarkers, FieldAtoms, InternalBaseFieldMeta, InternalFieldMeta, InternalFieldState, + SubfieldsMetaFlags, } from './fieldState.lib' -import type { ClientSideEvents } from '../events.public' +import type { + ValidatorInstance, + ValidatorInstances, +} from '../pipeline/validation/ValidatorInstance.lib' +import type { + ListenerInstance, + ListenerInstances, +} from '../pipeline/listeners/ListenerInstance.lib' import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' +import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' +import type { ReadonlyAtom } from '@tanstack/store' +import type { FieldUpdateOptions, Updater } from '../types.public' +import type { + ClientSideEvents, + ValidatorEvents, +} from '../pipeline/events.public' +import type { ClientValidationRun } from '../pipeline/validation/clientValidation.lib' +import type { FieldValidationOptions } from '../pipeline/validation/ownerValidation.lib' +import type { PipelineResult } from '../pipeline/state.lib' +import type { ResolvedInternalFieldUpdateOptions } from '../types.lib' +import type { FieldErrors } from '../validation/state.public' export type AnyFieldApiOptions = FieldApiOptions< any, @@ -92,80 +90,6 @@ export type AnyFieldApiOptions = FieldApiOptions< > export type AnyFieldValidator = FieldValidator -type FieldOptionItemWithWatchedFields = { - watchFields?: ReadonlyArray -} - -type FieldOptionsWithFieldNameReferences = { - name: string - validators?: ReadonlyArray - listeners?: ReadonlyArray -} - -function transformFieldOptionItemsWithWatchedFields< - TItem extends FieldOptionItemWithWatchedFields | undefined, ->( - items: ReadonlyArray | undefined, - transformFieldName: (fieldName: string) => string, -): Array | undefined { - if (!items) return undefined - - return items.map((item) => { - if (!item?.watchFields) return item - - return { - ...item, - watchFields: item.watchFields.map(transformFieldName), - } - }) -} - -export function transformFieldOptionsFieldNames< - TFieldOptions extends AnyFieldApiOptions, ->( - fieldOptions: TFieldOptions, - transformFieldName: (fieldName: string) => string, - mergeOptions: ( - props: TFieldOptions, - overrides: Partial, - ) => TFieldOptions, -): TFieldOptions { - const options = fieldOptions as TFieldOptions & - FieldOptionsWithFieldNameReferences - - const overrides = { - get name() { - return transformFieldName(options.name) - }, - } as Partial - - if (Object.hasOwn(options, 'validators')) { - Object.defineProperty(overrides, 'validators', { - enumerable: true, - get() { - return transformFieldOptionItemsWithWatchedFields( - options.validators, - transformFieldName, - ) - }, - }) - } - - if (Object.hasOwn(options, 'listeners')) { - Object.defineProperty(overrides, 'listeners', { - enumerable: true, - get() { - return transformFieldOptionItemsWithWatchedFields( - options.listeners, - transformFieldName, - ) - }, - }) - } - - return mergeOptions(fieldOptions, overrides) -} - export interface DefaultValueCacheEntry { name: string value: unknown @@ -200,86 +124,6 @@ function isObjectLike(value: unknown): boolean { ) } -/** - * @private - * Get a field api at the specified location. If it doesn't exist, - * it will create the necessary nodes to get it. - * - * @important This mutates the segments array. - */ -export function getOrCreateFieldApi( - node: AnyInternalFieldApi | InternalRootFieldApi, - segments: NameSegments, - form: AnyInternalFormApi, - options?: Omit, - scope: FieldOptionsScope = 'field', -): AnyInternalFieldApi { - const segment = segments.shift() - if (segment === undefined) { - // If trieNode is the root, we need to return a field node, not the root - if (node._isRoot) { - throw new Error('Root node cannot be a field API') - } - // Internal trie nodes defer their options until they are first requested as - // a configured field. Adapter updates handle subsequent option changes. - if (scope !== 'internal' && !node._fieldOptionsInitialized) { - node._update(options ?? {}, scope) - } - return node - } - - let childNode = node._getChild(segment) - if (childNode) { - return getOrCreateFieldApi(childNode, segments, form, options, scope) - } - - childNode = new form._FieldApi( - { - segment, - parent: node, - form: form, - }, - 'internal', - ) - - node._setChild(childNode) - if (segments.length === 0) { - const field = getOrCreateFieldApi(childNode, segments, form, options, scope) - devtools().fieldAdded?.(childNode) - return field - } - - devtools().fieldAdded?.(childNode) - - return getOrCreateFieldApi(childNode, segments, form, options, scope) -} - -/** - * @private - * - * @important This mutates the segments array. - */ -export function tryGetFieldApi( - trieNode: AnyInternalFieldApi | InternalRootFieldApi, - segments: NameSegments, -): AnyInternalFieldApi | null { - const segment = segments.shift() - if (segment === undefined) { - // If trieNode is the root, we cannot return it as a field API - if (!trieNode._isRoot) { - return trieNode - } - return null - } - - const childNode = trieNode._getChild(segment) - if (childNode) { - return tryGetFieldApi(childNode, segments) - } else { - return null - } -} - export interface InternalFieldApiParams extends Omit< AnyFieldApiOptions, 'name' @@ -292,27 +136,9 @@ export interface InternalFieldApiParams extends Omit< export type FieldOptionsScope = 'internal' | 'field' -export type FieldWatchingListenerFields = Map< - AnyInternalFieldApi, - Set -> -export type FieldWatchingValidatorFields = Map< - AnyInternalFieldApi, - Set -> - export type AnyInternalFieldApi = InternalFieldApi -export type InternalFieldListenerInstance = InternalListenerInstance< - AnyFieldListener, - AnyInternalFieldApi, - AnyInternalFieldApi -> -export type InternalFieldValidatorInstance = InternalValidatorInstance< - 'field', - AnyInternalFieldApi, - AnyInternalFieldApi, - AnyInternalFieldApi -> +export type InternalFieldListenerInstance = ListenerInstance<'field'> +export type InternalFieldValidatorInstance = ValidatorInstance<'field'> export class InternalFieldApi< TFormData, @@ -320,38 +146,36 @@ export class InternalFieldApi< TFieldValue extends DeepValue, > implements FieldApi { readonly _isRoot = false + // Every field currently creates a store, even if there are no errors yet and there's nothing to render. + // Perhaps if there's demand, we can change this sometime. + readonly _errorStore: ValidationErrorStore _parent: AnyInternalFieldApi | InternalRootFieldApi _childrenMap: Map = new Map() _defaultValueCache: DefaultValueCacheEntry | null = null _atoms: FieldAtoms - /** Stable runtime instances for this field's validator definitions. */ - _validatorInstances: InternalValidatorInstances< - 'field', - AnyInternalFieldApi, - AnyInternalFieldApi, - AnyInternalFieldApi - > - /** Stable runtime instances for this field's listener definitions. */ - _listenerInstances: InternalListenerInstances< - AnyFieldListener, - AnyInternalFieldApi, - AnyInternalFieldApi - > + /** Validators attached to this field. */ + _validatorInstances: ValidatorInstances<'field'> = null + /** Listeners attached to this field. */ + _listenerInstances: ListenerInstances<'field'> = null _errorVisibility: ErrorVisibilityFn | undefined _errorBoundary: boolean /** The form group occupying this trie node. */ _formGroup: AnyInternalFormGroupApi | null = null - // TODO implement + _watchedListenerNames: Set | null = null + _watchedValidatorNames: Set | null = null + /** - * @private - * Fields that are listening to this one. + * Whether this field has received usage-site options. + * Some actions like form error-routing or in-between fields could cause + * field nodes to exist before a render is supposed to create them. + * + * The main thing we're avoiding here is avoiding error reports from + * going from no validators -> N validators (since that'd usually be banned in the update) */ - _watchingListenerFields: FieldWatchingListenerFields | null - _watchingValidatorFields: FieldWatchingValidatorFields | null - /** Whether this trie node has received usage-site field options. */ _fieldOptionsInitialized: boolean _isKilled = false + private _validationRuns: ValidationRunTracker | null = null _segmentValue: NameSegment /** @@ -506,178 +330,30 @@ export class InternalFieldApi< this._segmentValue = segment this._parent = parent this.form = form - const normalizedValidators = - validators && validators.length > 0 - ? (validators as Array) - : null this._errorVisibility = errorVisibility this._errorBoundary = errorBoundary ?? false this._atoms = {} - this._watchingListenerFields = null - this._watchingValidatorFields = null - this._listenerInstances = reconcileListenerInstances< - AnyFieldListener, - AnyInternalFieldApi, - AnyInternalFieldApi - >({ - definitions: listeners, - instances: null, - owner: this, - }) - this._validatorInstances = reconcileValidatorInstances< - 'field', - AnyInternalFieldApi, - AnyInternalFieldApi, - AnyInternalFieldApi - >({ - definitions: normalizedValidators, - instances: null, - owner: this, - scope: 'field', - }) - - const reconciledListeners = reconcileWatchedListenerFields({ - field: this, - listenerInstances: this._listenerInstances, - form, - }) - reconciledListeners.attach.forEach(attachWatchingListenerField) - - const reconciledValidators = reconcileWatchedValidatorFields({ - field: this, - validatorInstances: this._validatorInstances, - form, + this._errorStore = new ValidationErrorStore({ + target: this, + onChange: (wasInvalid, isInvalid) => { + if (this._isKilled) return + const meta = this._getBaseMeta() + const previous = getSubfieldsMetaFlags(meta, wasInvalid) + const next = getSubfieldsMetaFlags(meta, isInvalid) + this._updateParentMeta(previous, next) + devtools().updateField?.(this) + }, }) - reconciledValidators.attach.forEach(attachWatchingValidatorField) + initializeFieldOptions(this, { validators, listeners }) } _update( options: Omit, scope: FieldOptionsScope = 'field', ) { - if (this._isKilled) return - - const isInitializing = - scope !== 'internal' && !this._fieldOptionsInitialized - const defaultOptions = - scope === 'field' ? this.form._defaultOptions?.field : undefined - const resolvedOptions = resolveDefaultOptions(options, defaultOptions) - - this._errorVisibility = resolvedOptions.errorVisibility - this._errorBoundary = resolvedOptions.errorBoundary ?? false - - const notifyDependencyChanges = devtools().fieldDependenciesChanged - const dependencyChanges: Array | null = - notifyDependencyChanges ? [] : null - - if (resolvedOptions.listeners) { - const previousListeners = this._listenerInstances?.map( - (instance) => instance.definition, - ) - this._listenerInstances = reconcileListenerInstances< - AnyFieldListener, - AnyInternalFieldApi, - AnyInternalFieldApi - >({ - definitions: resolvedOptions.listeners, - previousDefinitions: isInitializing - ? undefined - : (previousListeners ?? null), - instances: this._listenerInstances, - owner: this, - onBeforeDispose: (listenerInstance) => { - listenerInstance.resolvedWatchFields?.forEach((sourceField) => { - const operation = { - kind: 'listener' as const, - sourceField, - watchingField: this, - listenerInstance, - } - detachWatchingListenerField(operation) - dependencyChanges?.push(operation) - }) - listenerInstance.resolvedWatchFields = null - }, - }) - - const reconciledListeners = reconcileWatchedListenerFields({ - field: this, - listenerInstances: this._listenerInstances, - form: this.form, - }) - - reconciledListeners.detach.forEach((operation) => - detachWatchingListenerField(operation), - ) - reconciledListeners.attach.forEach(attachWatchingListenerField) - dependencyChanges?.push( - ...reconciledListeners.attach, - ...reconciledListeners.detach, - ) - } - - if (resolvedOptions.validators) { - const previousValidators = this._validatorInstances?.map( - (instance) => instance.definition, - ) - const nextValidators = - resolvedOptions.validators.length > 0 - ? (resolvedOptions.validators as Array) - : null - this._validatorInstances = reconcileValidatorInstances< - 'field', - AnyInternalFieldApi, - AnyInternalFieldApi, - AnyInternalFieldApi - >({ - definitions: nextValidators, - previousDefinitions: isInitializing - ? undefined - : (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, - validatorInstances: this._validatorInstances, - form: this.form, - }) - - reconciledValidators.detach.forEach((operation) => - detachWatchingValidatorField(operation), - ) - reconciledValidators.attach.forEach(attachWatchingValidatorField) - - dependencyChanges?.push( - ...reconciledValidators.attach, - ...reconciledValidators.detach, - ) - } - - if (dependencyChanges && dependencyChanges.length > 0) { - notifyDependencyChanges?.(dependencyChanges) - } - - if (scope !== 'internal') { - this._fieldOptionsInitialized = true - } + updateFieldOptions(this, options, scope) } /** @@ -716,42 +392,44 @@ export class InternalFieldApi< this._getOrCreateAtoms().meta.set((prevMeta) => { const newMeta = callUpdater(updater, prevMeta) - const prevContributions = getChildContributionStates(prevMeta) - const newContributions = getChildContributionStates(newMeta) - - if (!this._parent._isRoot) { - this._parent._updateChildContributionCount( - prevContributions, - newContributions, - ) - } else { - this._parent._updateFieldContributionCount( - prevContributions, - newContributions, - ) + const previousFlags = getSubfieldsMetaFlags( + prevMeta, + this._errorStore.hasErrors, + ) + const nextFlags = getSubfieldsMetaFlags( + newMeta, + this._errorStore.hasErrors, + ) - this._parent._updateErrorFields( - this, - prevContributions.error, - newContributions.error, - ) - } + this._updateParentMeta(previousFlags, nextFlags) return newMeta }) devtools().updateField?.(this) } + private _updateParentMeta( + previous: SubfieldsMetaFlags, + next: SubfieldsMetaFlags, + ): void { + if (!this._parent._isRoot) { + this._parent._updateChildMetaCounts(previous, next) + } else { + this._parent._updateFormMetaCounts(previous, next) + this._parent._updateErrorFields(this, previous.error, next.error) + } + } + /** * @private - * Called when a child's meta contribution changes. - * Increments/decrements the relevant child contribution count and propagates up. + * Updates counts of child fields with each meta flag, including their subfields, + * then updates parent meta. */ - _updateChildContributionCount( - prevState: ChildContributionStates, - newState: ChildContributionStates, + _updateChildMetaCounts( + previousFlags: SubfieldsMetaFlags, + nextFlags: SubfieldsMetaFlags, ): void { - updateChildContributionCount(this, prevState, newState) + updateChildMetaCounts(this, previousFlags, nextFlags) } /** @@ -759,82 +437,42 @@ export class InternalFieldApi< * Triggers validation for this field and all parent fields, * eventually calling form validation. */ - _triggerValidationCascade(event: ClientSideEvents): void { - if (this._isKilled) return - - const seenValidatorFields = new WeakSet() - - visitFieldAndAncestors(this, (current, stop) => { - if (current._isKilled) return stop - - current._runFieldValidation(event) - current._notifyValidator(event, seenValidatorFields) - return undefined - }) - - const group = this._getFormGroup() - if (group) { - group.validate(event, { triggerFieldApi: this }) - return - } - - this.form.validate(event, { fieldApiOverride: this }) + _triggerValidationFromField(event: ClientSideEvents): void { + triggerValidationFromField(this, event) } /** * @private - * Runs this field's validation pipeline. + * Runs this field's validation pipeline. Undefined if an error occurred that was caught. */ - async _runFieldValidation( - event: 'change' | 'blur' | 'submit', - options?: { - onResult?: boolean - onlyRunValidatorInstances?: ReadonlySet | null - _startValidation?: () => () => void - }, - ): Promise { - if (this._isKilled) - return { - results: [], - hasErrors: false, - thrownError: null, - } + _runFieldValidation( + event: 'mount' | 'change' | 'blur' | 'submit', + options: FieldValidationOptions, + ): ClientValidationRun | undefined { + const isFieldOwnedRun = !options.submission + const hasLiveValidators = + !this._isKilled && this._validatorInstances !== null - const validators = this._validatorInstances + if (isFieldOwnedRun && hasLiveValidators) { + this._validationRuns ??= new ValidationRunTracker() + } - if (!validators) - return { - results: [], - hasErrors: false, - thrownError: null, - } + return startFieldValidation(this, this._validationRuns, event, options) + } - let finishValidation = options?._startValidation?.() - if (!finishValidation) { - this._setValidationCount((count) => count + 1) - finishValidation = () => { - this._setValidationCount((count) => Math.max(0, count - 1)) - } - } + /** Cancels this node's runs, including those owned by its form group. */ + _cancelValidation(): void { + batch(() => { + this._validationRuns?.cancelAll() + this._formGroup?._cancelOwnedValidation() + }) + } - try { - return await runFieldValidatorPipeline({ - pipeline: validators, - context: { - scope: 'field', - event, - fieldApi: this, - formApi: this.form, - }, - onResult: - options?.onResult !== false - ? (result) => this._processValidationResult(result, event) - : undefined, - validatorInstancesToRun: options?.onlyRunValidatorInstances ?? null, - }) - } finally { - finishValidation() - } + _resetValidation(): void { + batch(() => { + this._cancelValidation() + this._errorStore.clearTargetErrors() + }) } _setValidationCount(updater: Updater): void { @@ -858,256 +496,48 @@ export class InternalFieldApi< } _processValidationResult( - result: PipelineResult, - sourceEvent: string, + { value, instance }: PipelineResult, + sourceEvent: ValidatorEvents, ) { if (this._isKilled) return - const validatorInstance = - result.validatorInstance as InternalFieldValidatorInstance - - this._setMeta((prev) => { - const { self } = parseValidationResult(result.result) - const nextErrors = setValidationSourceError( - prev._validationSourceErrors, - validatorInstance, - self ?? [], - sourceEvent, - ) + const selfErrors = parseValidationResult(value.result).self ?? [] - if (!nextErrors) return prev - - if (self && self.length > 0) { - validatorInstance.addErrorTarget(this) - } else { - validatorInstance.deleteErrorTarget(this) - } - - return { - ...prev, - _validationSourceErrors: nextErrors.errorMap, - } satisfies InternalBaseFieldMeta - }) + replaceSourceErrors(instance, sourceEvent, [[this, selfErrors]]) } - /** 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) + removeSourceErrors(validatorInstance) + this._pruneIfUnused() } - _clearEventErrors(event: 'change' | 'blur', sourceEvent: string): void { - if (this._isKilled) return - - const validators = this._validatorInstances - if (!validators) return - - 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) { - validatorInstancesToClear.push(validatorInstance) - } - } - - if (validatorInstancesToClear.length === 0) return - const clearedInstances = validatorInstancesToClear.filter( - (validatorInstance) => - this._getBaseMeta()._validationSourceErrors?.get(validatorInstance) - ?.sourceEvent === sourceEvent, + _clearStaleErrors( + shouldClear: (instance: InternalFieldValidatorInstance) => boolean = () => + true, + ): void { + this._errorStore.clearStaleErrors( + (source) => + source.scope === validationSourceScopes.field && shouldClear(source), ) - if (clearedInstances.length === 0) return - - this._setMeta((prev) => { - const clearedErrors = clearValidationSourceErrorsFromEvent( - prev._validationSourceErrors, - clearedInstances, - sourceEvent, - ) - - if (!clearedErrors) return prev - - clearedInstances.forEach((validatorInstance) => - validatorInstance.deleteErrorTarget(this), - ) - - return { - ...prev, - _validationSourceErrors: clearedErrors.errorMap, - } satisfies InternalBaseFieldMeta - }) - this._pruneIfUnused() } - _notifyEvent( + _handleEvent( options: Omit< ResolvedInternalFieldUpdateOptions, 'fieldApiOverride' | '_skipFieldCreation' >, event: 'change' | 'blur' | 'submit', ): void { - if (this._isKilled) return - - if (event === 'change' || event === 'blur') { - this._clearEventErrors(event, 'submit') - - if (event === 'blur') { - this._clearEventErrors('blur', 'mount') - this.form._clearEventErrors(this, 'submit', event) - this.form._clearEventErrors(this, 'server', event) - this.form._clearEventErrors(this, 'mount', event) - } - } - - const { - markAsDirty, - markAsTouched, - causeValidation, - markAsBlurred, - doPropagate, - } = options - // Not sure if we lose this context, so might as well - const originalField = this - - batch(() => { - const seenListenerFields = new WeakSet() - - visitFieldAndAncestors(this, (currNode, stop) => { - const isOriginalField = currNode === originalField - const { isSelfDirty, isSelfTouched, isBlurred } = currNode.meta - const shouldUpdateDirty = isOriginalField && markAsDirty && !isSelfDirty - const shouldUpdateTouched = - isOriginalField && markAsTouched && !isSelfTouched - - const shouldUpdateBlurred = markAsBlurred && !isBlurred - - if (shouldUpdateDirty || shouldUpdateTouched || shouldUpdateBlurred) { - currNode._setMeta((prev) => ({ - ...prev, - isTouched: shouldUpdateTouched ? true : prev.isTouched, - isDirty: shouldUpdateDirty ? true : prev.isDirty, - isBlurred: markAsBlurred ? true : prev.isBlurred, - })) - } - - currNode._notifyListener(event, seenListenerFields) - - if (!doPropagate) return stop - return undefined - }) - }) - - if (causeValidation) { - this._triggerValidationCascade(event) - } + handleFieldEvent(this, options, event) } - _notifyListener( - trigger: FieldListenerTriggers, - seenFields: WeakSet, - onlyRunListenerInstances: ReadonlySet | null = null, - ) { - if (this._isKilled) return - - // Field A listens to Field B listens to field A - // FieldA.notifyListener -> fieldB.notifyListener -> fieldA.notifyLister - if (seenFields.has(this)) { - console.warn( - `Field listener: cyclical listener cycle detected. Check around the field ${this.name}`, - ) - return - } - - seenFields.add(this) - - if (this._listenerInstances) { - runFieldListenerPipeline({ - pipeline: this._listenerInstances, - context: { - event: trigger, - fieldApi: this, - formApi: this.form, - }, - listenerInstancesToRun: onlyRunListenerInstances, - }) - } - - const watchingFields = this._watchingListenerFields - if (!watchingFields) return - - for (const [watchingField, listenerInstances] of watchingFields) { - if (watchingField._isKilled) { - watchingFields.delete(watchingField) - continue - } - - watchingField._notifyListener(trigger, seenFields, listenerInstances) - } - - if (watchingFields.size === 0) { - this._watchingListenerFields = null - } + _notifyListener(options: NotifyFieldListenerOptions) { + notifyFieldListener(this, options) } - _notifyValidator( - trigger: 'change' | 'blur' | 'submit', - seenFields: WeakSet, - onlyRunValidatorInstances: ReadonlySet | null = null, - ) { - if (this._isKilled) return - - if (seenFields.has(this)) { - console.warn( - `Field validator: cyclical validator cycle detected. Check around the field ${this.name}`, - ) - return - } - - seenFields.add(this) - - if (this._validatorInstances && onlyRunValidatorInstances) { - this._runFieldValidation(trigger, { onlyRunValidatorInstances }) - } - - const watchingFields = this._watchingValidatorFields - if (!watchingFields) return - - for (const [watchingField, validatorInstances] of watchingFields) { - if (watchingField._isKilled) { - watchingFields.delete(watchingField) - continue - } - - watchingField._notifyValidator(trigger, seenFields, validatorInstances) - } - - if (watchingFields.size === 0) { - this._watchingValidatorFields = null - } + _notifyValidator(options: NotifyFieldValidatorOptions) { + notifyFieldValidator(this, options) } /** @@ -1121,59 +551,22 @@ export class InternalFieldApi< } const isMountTransition = this._refCount === 0 - const mountValidatorInstances = isMountTransition - ? (this._validatorInstances?.filter( - (validatorInstance) => !validatorInstance.didRunOnMount, - ) ?? []) - : [] + this._refCount++ this._getOrCreateAtoms() if (isMountTransition) { devtools().mountField?.(this) } - this._notifyListener('mount', new WeakSet()) + this._notifyListener({ trigger: 'mount', seenFields: new WeakSet() }) - if (mountValidatorInstances.length > 0) { - mountValidatorInstances.forEach((validatorInstance) => - validatorInstance.markMountValidationRan(), - ) - this._runMountValidation(mountValidatorInstances) + if (isMountTransition) { + this._runFieldValidation('mount', { catchErrors: true }) } return () => this._unregister() } - /** - * @private - * Runs validators with an enabled mount trigger on the first component mount. - */ - _runMountValidation(validators = this._validatorInstances): void { - if (!validators || validators.length === 0) return - - this._setValidationCount((count) => count + 1) - - const { didRun, asyncPromise } = runFieldMountValidatorPipeline({ - pipeline: validators, - fieldApi: this, - onResult: (result) => this._processValidationResult(result, 'mount'), - }) - - if (!didRun) { - this._setValidationCount((count) => Math.max(0, count - 1)) - return - } - - if (asyncPromise) { - void asyncPromise.finally(() => { - this._setValidationCount((count) => Math.max(0, count - 1)) - }) - return - } - - this._setValidationCount((count) => Math.max(0, count - 1)) - } - /** * @private * Unregister as a component that you're using this field. @@ -1182,13 +575,15 @@ export class InternalFieldApi< _unregister(): void { if (this._isKilled) return + // TODO we have an RFC for what unmounts should do with validators and listeners. + const previousPath = this.name const isUnmountTransition = this._refCount === 1 this._refCount-- if (isUnmountTransition) { devtools().unmountField?.(this, previousPath) } - this._notifyListener('unmount', new WeakSet()) + this._notifyListener({ trigger: 'unmount', seenFields: new WeakSet() }) if (this._refCount <= 0) { setTimeout(() => { @@ -1398,11 +793,14 @@ export class InternalFieldApi< handleBlur = (): void => { if (this._isKilled) return - this._notifyEvent( + // TODO A workplan could let users configure whether listeners run before or after validation. + // not overly important but could be useful. + + this._handleEvent( { markAsDirty: false, causeValidation: true, - doPropagate: true, + notifyParentFields: true, markAsTouched: true, markAsBlurred: true, }, diff --git a/packages/form-core/src/FieldApi/FieldApi.public.ts b/packages/form-core/src/FieldApi/FieldApi.public.ts index 762c55fba..9804841f3 100644 --- a/packages/form-core/src/FieldApi/FieldApi.public.ts +++ b/packages/form-core/src/FieldApi/FieldApi.public.ts @@ -1,15 +1,15 @@ -import type { TryGetArrayElementType } from '../deep-keys.public' -import type { FieldUpdateOptions, Updater } from '../types.public' -import type { FormApi } from '../FormApi/FormApi.public' import type { - FieldValidators, + ScopedErrorTypes, ToFieldError, FieldErrors as ValidationFieldErrors, -} from '../validation.public' -import type { FieldListeners } from '../listeners.public' +} from '../validation/state.public' +import type { TryGetArrayElementType } from '../deep-keys.public' +import type { FieldUpdateOptions, Updater } from '../types.public' +import type { FormApi } from '../FormApi/FormApi.public' import type { ReadonlyAtom } from '@tanstack/store' -import type { ScopedErrorTypes } from '../validation/state.public' +import type { FieldValidators } from '../pipeline/validation/validation.public' import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' +import type { FieldListeners } from '../pipeline/listeners/listeners.public' export interface BaseFieldMeta { isTouched: boolean diff --git a/packages/form-core/src/FieldApi/RootFieldApi.lib.ts b/packages/form-core/src/FieldApi/RootFieldApi.lib.ts index 5f0cfe382..b87717df1 100644 --- a/packages/form-core/src/FieldApi/RootFieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/RootFieldApi.lib.ts @@ -1,19 +1,19 @@ import { batch } from '@tanstack/store' -import { touchAllFieldsAndCollectSubmitValidators } from './fieldTree.lib' -import type { AnyInternalFieldApi } from './FieldApi.lib' -import type { ChildContributionStates } from './fieldState.lib' +import { ValidationErrorStore } from '../validation/errorStorage.lib' import type { AnyInternalFormApi, FormMetaAtoms } from '../FormApi/FormApi.lib' import type { NameSegment } from '../utils.lib' +import type { AnyInternalFieldApi } from './FieldApi.lib' +import type { SubfieldsMetaFlags } from './fieldState.lib' -export type RootCounterContributionKey = 'touched' | 'validating' +export type FormMetaCountKey = 'touched' | 'validating' -export const rootCounterContributionKeys: Array = [ +export const formMetaCountKeys: Array = [ 'touched', 'validating', ] -const rootCounterMetaKeys: Record< - RootCounterContributionKey, +const formMetaCountAtomKeys: Record< + FormMetaCountKey, keyof Pick > = { touched: 'touchedFieldCount', @@ -22,6 +22,12 @@ const rootCounterMetaKeys: Record< export class InternalRootFieldApi { readonly _isRoot = true + /** + * The errors of the form itself (AKA a field with name ''). + */ + readonly _errorStore: ValidationErrorStore = new ValidationErrorStore({ + target: this, + }) #children: Map = new Map() form: AnyInternalFormApi @@ -36,6 +42,10 @@ export class InternalRootFieldApi { this.form = form } + _clearErrors(): void { + this._errorStore.clearTargetErrors() + } + /** * @private * Get a child FieldApi by its segment name. @@ -54,15 +64,15 @@ export class InternalRootFieldApi { _updateErrorFields( node: AnyInternalFieldApi, - prevContributes: boolean, - newContributes: boolean, + wasInvalid: boolean, + isInvalid: boolean, ) { - if (prevContributes === newContributes) return + if (wasInvalid === isInvalid) return this.form._atoms.meta.errorFields.set((prev) => { const errorFields = new Set(prev) - if (newContributes) { + if (isInvalid) { errorFields.add(node) } else { errorFields.delete(node) @@ -72,20 +82,20 @@ export class InternalRootFieldApi { }) } - _updateFieldContributionCount( - prevState: ChildContributionStates, - newState: ChildContributionStates, + _updateFormMetaCounts( + previousFlags: SubfieldsMetaFlags, + nextFlags: SubfieldsMetaFlags, ) { batch(() => { - for (const key of rootCounterContributionKeys) { - const prevContributes = prevState[key] - const newContributes = newState[key] + for (const key of formMetaCountKeys) { + const wasSet = previousFlags[key] + const isSet = nextFlags[key] - if (prevContributes === newContributes) continue + if (wasSet === isSet) continue - const atom = this.form._atoms.meta[rootCounterMetaKeys[key]] + const atom = this.form._atoms.meta[formMetaCountAtomKeys[key]] atom.set((prev) => { - const delta = newContributes ? 1 : -1 + const delta = isSet ? 1 : -1 return Math.max(0, prev + delta) }) } @@ -95,8 +105,4 @@ export class InternalRootFieldApi { _removeChild(segment: NameSegment): void { this.#children.delete(segment) } - - _touchAllFieldsAndCollectSubmitValidators(): Array { - return touchAllFieldsAndCollectSubmitValidators(this) - } } diff --git a/packages/form-core/src/FieldApi/fieldEvents.lib.ts b/packages/form-core/src/FieldApi/fieldEvents.lib.ts new file mode 100644 index 000000000..90b8e0794 --- /dev/null +++ b/packages/form-core/src/FieldApi/fieldEvents.lib.ts @@ -0,0 +1,290 @@ +import { batch } from '@tanstack/store' +import { runFieldListenerPipeline } from '../pipeline/listeners/listeners.lib' +import { canonicalFieldName, nameToFieldNodeSegments } from '../utils.lib' +import { visitFieldAndAncestors } from './fieldTraversal.lib' +import type { AnyInternalFieldApi } from './FieldApi.lib' +import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' +import type { ClientSideEvents } from '../pipeline/events.public' +import type { FieldListenerTriggers } from '../pipeline/listeners/listeners.public' +import type { ResolvedInternalFieldUpdateOptions } from '../types.lib' + +export interface NotifyFieldListenerOptions { + trigger: FieldListenerTriggers + seenFields: WeakSet + watchedFieldName?: string +} + +export interface NotifyFieldValidatorOptions { + trigger: ClientSideEvents + seenFields: WeakSet + watchedFieldName?: string +} + +/** + * Starts validation for a field and its ancestors, notifies their watching + * validators, then starts validation for the containing form group or form. + * + * Returns without waiting for asynchronous validation. Validation failures are + * reported to the console. + */ +export function triggerValidationFromField( + field: AnyInternalFieldApi, + event: ClientSideEvents, +): void { + if (field._isKilled) return + + const seenValidatorFields = new WeakSet() + + visitFieldAndAncestors(field, (current, stop) => { + if (current._isKilled) return stop + + current._runFieldValidation(event, { + catchErrors: true, + _invalidateOnChange: event === 'change', + }) + current._notifyValidator({ + trigger: event, + seenFields: seenValidatorFields, + }) + return + }) + + const group = field._getFormGroup() + if (group) { + void group + .validate(event, { triggerFieldApi: field }) + .catch((error) => console.error(error)) + } else { + void field.form + .validate(event, { fieldApiOverride: field }) + .catch((error) => console.error(error)) + } +} + +/** + * Handles various field-related actions like + * - changing meta + * - notifying validators + * - notifying listeners + * + * Fine-tune with the given options object. + */ +export function handleFieldEvent( + field: AnyInternalFieldApi, + options: Omit< + ResolvedInternalFieldUpdateOptions, + 'fieldApiOverride' | '_skipFieldCreation' + >, + event: 'change' | 'blur' | 'submit', +): void { + if (field._isKilled) return + + const { + markAsDirty, + markAsTouched, + causeValidation, + markAsBlurred, + notifyParentFields, + } = options + + batch(() => { + const seenListenerFields = new WeakSet() + + visitFieldAndAncestors(field, (currNode, stop) => { + const isOriginalField = currNode === field + const { isSelfDirty, isSelfTouched, isBlurred } = currNode.meta + const shouldUpdateDirty = isOriginalField && markAsDirty && !isSelfDirty + const shouldUpdateTouched = + isOriginalField && markAsTouched && !isSelfTouched + + const shouldUpdateBlurred = markAsBlurred && !isBlurred + + if (shouldUpdateDirty || shouldUpdateTouched || shouldUpdateBlurred) { + currNode._setMeta((prev) => ({ + ...prev, + isTouched: shouldUpdateTouched ? true : prev.isTouched, + isDirty: shouldUpdateDirty ? true : prev.isDirty, + isBlurred: markAsBlurred ? true : prev.isBlurred, + })) + } + + currNode._notifyListener({ + trigger: event, + seenFields: seenListenerFields, + }) + + if (!notifyParentFields) return stop + return undefined + }) + }) + + if (event === 'change') field.form._clearStaleErrors(field, causeValidation) + + if (causeValidation) { + field._triggerValidationFromField(event) + } +} + +/** + * Start listeners of this field and watching fields. + * + * `watchedFieldName` limits execution to listeners watching that path. + */ +export function notifyFieldListener( + field: AnyInternalFieldApi, + { trigger, seenFields, watchedFieldName }: NotifyFieldListenerOptions, +): void { + if (field._isKilled) return + + // Field A listens to Field B listens to field A + // FieldA.notifyListener -> fieldB.notifyListener -> fieldA.notifyLister + if (seenFields.has(field)) { + console.warn( + `Field listener: cyclical listener cycle detected. Check around the field ${field.name}`, + ) + return + } + + seenFields.add(field) + + if (field._listenerInstances) { + runFieldListenerPipeline({ + pipeline: field._listenerInstances, + context: { + event: trigger, + fieldApi: field, + formApi: field.form, + }, + watchedFieldName, + }) + } + + // Listener callbacks can dispose this field after the initial check. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!field._isKilled) { + notifyWatchingFieldListeners(field.form, field.name, trigger, seenFields) + } +} + +/** + * Forwards an event to live fields with listeners watching `sourceFieldName`, + * including when the source has no field node. + * + * @param sourceFieldName - Full form path of the field that reported the event. + */ +export function notifyWatchingFieldListeners( + form: AnyInternalFormApi, + sourceFieldName: string, + trigger: FieldListenerTriggers, + seenFields: WeakSet, +): void { + const watchingFields = form._watchingListenerFields.get(sourceFieldName) + for (const watchingField of watchingFields ?? []) { + if (!watchingField._isKilled) { + watchingField._notifyListener({ + trigger, + seenFields, + watchedFieldName: sourceFieldName, + }) + } + } +} + +export function notifyFieldValidator( + field: AnyInternalFieldApi, + { trigger, seenFields, watchedFieldName }: NotifyFieldValidatorOptions, +): void { + if (field._isKilled) return + + if (seenFields.has(field)) { + console.warn( + `Field validator: cyclical validator cycle detected. Check around the field ${field.name}`, + ) + return + } + + seenFields.add(field) + + if (field._validatorInstances && watchedFieldName !== undefined) { + field._runFieldValidation(trigger, { + catchErrors: true, + watchedFieldName, + }) + } + + // Result subscribers can dispose this field after the initial check. + if (!(field._isKilled as boolean)) { + notifyWatchingFieldValidators(field.form, field.name, trigger, seenFields) + } +} + +/** + * Forwards an event to live fields with validators watching `sourceFieldName`, + * including when the source has no field node. + * + * @param sourceFieldName - Full form path of the field that reported the event. + * @param seenFields - Shared set of fields already notified for this event. + */ +export function notifyWatchingFieldValidators( + form: AnyInternalFormApi, + sourceFieldName: string, + trigger: ClientSideEvents, + seenFields: WeakSet, +): void { + const watchingFields = form._watchingValidatorFields.get(sourceFieldName) + for (const watchingField of watchingFields ?? []) { + if (!watchingField._isKilled) { + watchingField._notifyValidator({ + trigger, + seenFields, + watchedFieldName: sourceFieldName, + }) + } + } +} + +/** + * If field creation is fine, @see {handleFieldEvent} + */ +export function handleFieldChangeWithoutCreatingField( + form: AnyInternalFormApi, + fieldName: string, + options: ResolvedInternalFieldUpdateOptions, +): void { + const segments = nameToFieldNodeSegments(fieldName) + const paths: Array = [] + while (segments.length) { + paths.push(canonicalFieldName(segments)) + segments.pop() + } + const seenListeners = new WeakSet() + for (const name of paths) { + const field = form._tryGetFieldApi(name) + if (field) { + field._notifyListener({ trigger: 'change', seenFields: seenListeners }) + } else notifyWatchingFieldListeners(form, name, 'change', seenListeners) + if (!options.notifyParentFields) break + } + + const ancestor = + paths + .map((name) => form._tryGetFieldApi(name)) + .find((field) => field !== null) ?? null + form._clearStaleErrors(ancestor, options.causeValidation) + if (!options.causeValidation) return + + const seenValidators = new WeakSet() + for (const name of paths) { + const field = form._tryGetFieldApi(name) + if (field) { + field._runFieldValidation('change', { + catchErrors: true, + _invalidateOnChange: true, + }) + field._notifyValidator({ trigger: 'change', seenFields: seenValidators }) + } else notifyWatchingFieldValidators(form, name, 'change', seenValidators) + } + const group = ancestor?._getFormGroup() + const validation = group ? group.validate('change') : form.validate('change') + void validation.catch((error) => console.error(error)) +} diff --git a/packages/form-core/src/FieldApi/fieldOptions.lib.ts b/packages/form-core/src/FieldApi/fieldOptions.lib.ts new file mode 100644 index 000000000..0ff456b4e --- /dev/null +++ b/packages/form-core/src/FieldApi/fieldOptions.lib.ts @@ -0,0 +1,197 @@ +import { batch } from '@tanstack/store' +import { devtools } from '../devtoolsBridge.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' +import { ListenerInstance } from '../pipeline/listeners/ListenerInstance.lib' +import { ValidatorInstance } from '../pipeline/validation/ValidatorInstance.lib' +import { updateFieldWatches } from './linked-fields.lib' +import type { + AnyFieldApiOptions, + AnyFieldValidator, + AnyInternalFieldApi, + FieldOptionsScope, +} from './FieldApi.lib' +import type { FieldDependencyChange } from '../devtoolsBridge.lib' + +type FieldOptionItemWithWatchedFields = { + watchFields?: ReadonlyArray +} + +type FieldOptionsWithFieldNameReferences = { + name: string + validators?: ReadonlyArray + listeners?: ReadonlyArray +} + +function transformFieldOptionItemsWithWatchedFields< + TItem extends FieldOptionItemWithWatchedFields | undefined, +>( + items: ReadonlyArray | undefined, + transformFieldName: (fieldName: string) => string, +): Array | undefined { + if (!items) return undefined + + return items.map((item) => { + if (!item?.watchFields) return item + + return { + ...item, + watchFields: item.watchFields.map(transformFieldName), + } + }) +} + +/** + * Transforms the field name and validator/listener `watchFields` using getter overrides. + */ +export function transformFieldOptionsFieldNames< + TFieldOptions extends AnyFieldApiOptions, +>( + fieldOptions: TFieldOptions, + transformFieldName: (fieldName: string) => string, + mergeOptions: ( + props: TFieldOptions, + overrides: Partial, + ) => TFieldOptions, +): TFieldOptions { + const options = fieldOptions as TFieldOptions & + FieldOptionsWithFieldNameReferences + + // We need to delay the call of accessors like `options.name`. Some adapters like + // Solid use signals for that, so if we call it early it'd lose the reactivity. + const overrides = { + get name() { + return transformFieldName(options.name) + }, + } as Partial + + if (Object.hasOwn(options, 'validators')) { + Object.defineProperty(overrides, 'validators', { + enumerable: true, + get() { + return transformFieldOptionItemsWithWatchedFields( + options.validators, + transformFieldName, + ) + }, + }) + } + + if (Object.hasOwn(options, 'listeners')) { + Object.defineProperty(overrides, 'listeners', { + enumerable: true, + get() { + return transformFieldOptionItemsWithWatchedFields( + options.listeners, + transformFieldName, + ) + }, + }) + } + + return mergeOptions(fieldOptions, overrides) +} + +/** + * Creates listener and validator instances and registers the fields they watch. + */ +export function initializeFieldOptions( + field: AnyInternalFieldApi, + { + validators, + listeners, + }: Pick, +): void { + field._listenerInstances = ListenerInstance.update({ + definitions: listeners, + instances: null, + scope: 'field', + }) + field._validatorInstances = ValidatorInstance.update({ + definitions: validators, + instances: null, + owner: field, + scope: 'field', + }) + + updateFieldWatches(field, 'listener') + updateFieldWatches(field, 'validator') +} + +/** + * Applies options in a batch, updating error settings, listener and validator + * instances, and watched field registrations. + */ +export function updateFieldOptions( + field: AnyInternalFieldApi, + options: Omit, + scope: FieldOptionsScope = 'field', +): void { + // TODO this is migrated, but I'm having a hard time following what's going on here. + // could use a cleanup similar to initializeFieldOptions. + batch(() => { + if (field._isKilled) return + + const isInitializing = + scope !== 'internal' && !field._fieldOptionsInitialized + const defaultOptions = + scope === 'field' ? field.form._defaultOptions?.field : undefined + const resolvedOptions = resolveDefaultOptions(options, defaultOptions) + + field._errorVisibility = resolvedOptions.errorVisibility + field._errorBoundary = resolvedOptions.errorBoundary ?? false + + const notifyDependencyChanges = devtools().fieldDependenciesChanged + const dependencyChanges: Array | undefined = + notifyDependencyChanges ? [] : undefined + + if (resolvedOptions.listeners) { + const previousListeners = field._listenerInstances?.map( + (instance) => instance.definition, + ) + field._listenerInstances = ListenerInstance.update({ + definitions: resolvedOptions.listeners, + previousDefinitions: isInitializing + ? undefined + : (previousListeners ?? null), + instances: field._listenerInstances, + scope: 'field', + }) + updateFieldWatches(field, 'listener', dependencyChanges) + } + + if (resolvedOptions.validators) { + const previousValidators = field._validatorInstances?.map( + (instance) => instance.definition, + ) + const nextValidators = + resolvedOptions.validators.length > 0 + ? (resolvedOptions.validators as Array) + : null + if ( + (nextValidators?.length ?? 0) < (field._validatorInstances?.length ?? 0) + ) { + field._cancelValidation() + } + field._validatorInstances = ValidatorInstance.update({ + definitions: nextValidators, + previousDefinitions: isInitializing + ? undefined + : (previousValidators ?? null), + instances: field._validatorInstances, + owner: field, + scope: 'field', + onBeforeDispose: (validatorInstance) => + field._removeValidatorInstance(validatorInstance), + }) + updateFieldWatches(field, 'validator', dependencyChanges) + } + + if (dependencyChanges && dependencyChanges.length > 0) { + notifyDependencyChanges?.(dependencyChanges) + } + + if (scope !== 'internal') { + field._fieldOptionsInitialized = true + } + }) +} diff --git a/packages/form-core/src/FieldApi/fieldState.lib.ts b/packages/form-core/src/FieldApi/fieldState.lib.ts index d6d336f23..76a764acb 100644 --- a/packages/form-core/src/FieldApi/fieldState.lib.ts +++ b/packages/form-core/src/FieldApi/fieldState.lib.ts @@ -1,28 +1,24 @@ import { createFormStateProxy } from '../FormApi/formState.lib' -import { getValidationSourceErrors } from '../validation' -import type { RootCounterContributionKey } from './RootFieldApi.lib' -import type { AnyInternalFieldApi } from './FieldApi.lib' +import type { FormMetaCountKey } from './RootFieldApi.lib' import type { AnyFieldMeta as AnyPublicFieldMeta, BaseFieldMeta, FieldState as PublicFieldState, SubfieldsMeta, } from './FieldApi.public' -import type { ValidationSourceErrorMap } from '../validation' import type { Atom, ReadonlyAtom } from '@tanstack/store' -import type { ValidationIssue } from '../validation/state.public' +import type { ValidationIssue } from '../validation/results/results.public' +import type { AnyInternalFieldApi } from './FieldApi.lib' import type { - ErrorVisibilityContext, ErrorVisibilityFieldState, ErrorVisibilityFn, } from '../FormApi/errorVisibility.public' -export type ChildContributionKey = - RootCounterContributionKey | 'dirty' | 'error' -type ChildContributionCounts = Record -export type ChildContributionStates = Record +export type SubfieldsMetaKey = FormMetaCountKey | 'dirty' | 'error' +type ChildMetaCounts = Record +export type SubfieldsMetaFlags = Record -export const childContributionKeys: Array = [ +export const subfieldsMetaKeys: Array = [ 'touched', 'dirty', 'error', @@ -30,14 +26,7 @@ export const childContributionKeys: Array = [ ] interface MetaExtension { - /** - * 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 + childMetaCounts: ChildMetaCounts _validationCount: number /** * @private @@ -67,6 +56,8 @@ export interface FieldAtoms { meta?: Atom } +const emptyErrors: Array = [] + export const defaultBaseFieldMeta: BaseFieldMeta = { isTouched: false, isDirty: false, @@ -76,14 +67,13 @@ export const defaultBaseFieldMeta: BaseFieldMeta = { export const defaultInternalBaseFieldMeta: InternalBaseFieldMeta = { ...defaultBaseFieldMeta, - childContributionCounts: { + childMetaCounts: { touched: 0, dirty: 0, error: 0, validating: 0, }, _validationCount: 0, - _validationSourceErrors: null, _arrayVersion: 0, } @@ -124,19 +114,19 @@ export function deriveFromBaseFieldMeta( value, isDefaultValue, ) - const originalErrors = getErrorsFromBaseMeta(baseMeta, previousMeta) + const originalErrors = field?._errorStore.errors ?? emptyErrors const errors = canDisplayErrors ? originalErrors : [] const isSelfTouched = baseMeta.isTouched const isSelfDirty = baseMeta.isDirty const isSelfValid = errors.length === 0 const isOriginalSelfValid = originalErrors.length === 0 const subfields: SubfieldsMeta = { - isEveryValid: baseMeta.childContributionCounts.error === 0, - isAnyInvalid: baseMeta.childContributionCounts.error > 0, - isEveryPristine: baseMeta.childContributionCounts.dirty === 0, - isSomeDirty: baseMeta.childContributionCounts.dirty > 0, - isSomeTouched: baseMeta.childContributionCounts.touched > 0, - isSomeValidating: baseMeta.childContributionCounts.validating > 0, + isEveryValid: baseMeta.childMetaCounts.error === 0, + isAnyInvalid: baseMeta.childMetaCounts.error > 0, + isEveryPristine: baseMeta.childMetaCounts.dirty === 0, + isSomeDirty: baseMeta.childMetaCounts.dirty > 0, + isSomeTouched: baseMeta.childMetaCounts.touched > 0, + isSomeValidating: baseMeta.childMetaCounts.validating > 0, } const isTouched = isSelfTouched || subfields.isSomeTouched const isDirty = isSelfDirty || subfields.isSomeDirty @@ -246,9 +236,9 @@ function createErrorVisibilityFieldState( meta: InternalBaseFieldMeta, isDefaultValue: boolean, ): ErrorVisibilityFieldState { - const isSomeTouched = meta.childContributionCounts.touched > 0 - const isSomeDirty = meta.childContributionCounts.dirty > 0 - const isSomeValidating = meta.childContributionCounts.validating > 0 + const isSomeTouched = meta.childMetaCounts.touched > 0 + const isSomeDirty = meta.childMetaCounts.dirty > 0 + const isSomeValidating = meta.childMetaCounts.validating > 0 const isSelfTouched = meta.isTouched const isSelfDirty = meta.isDirty const isSelfValidating = meta.isValidating @@ -277,26 +267,18 @@ function createErrorVisibilityFieldState( } } -export function getChildContributionStates( +export function getSubfieldsMetaFlags( meta: InternalBaseFieldMeta, -): ChildContributionStates { + hasOwnErrors = false, +): SubfieldsMetaFlags { return { - touched: meta.isTouched || meta.childContributionCounts.touched > 0, - dirty: meta.isDirty || meta.childContributionCounts.dirty > 0, - validating: - meta.isValidating || meta.childContributionCounts.validating > 0, - error: - getErrorsFromBaseMeta(meta).length > 0 || - meta.childContributionCounts.error > 0, + touched: meta.isTouched || meta.childMetaCounts.touched > 0, + dirty: meta.isDirty || meta.childMetaCounts.dirty > 0, + validating: meta.isValidating || meta.childMetaCounts.validating > 0, + error: hasOwnErrors || meta.childMetaCounts.error > 0, } } -function hasValidationSourceErrors( - errors: ValidationSourceErrorMap | null, -): boolean { - return errors !== null && errors.size > 0 -} - export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { if (meta.isTouched) return false if (meta.isDirty) return false @@ -304,31 +286,13 @@ export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { if (meta.isValidating) return false if (meta._validationCount !== 0) return false if (meta._arrayVersion !== 0) return false - if (hasValidationSourceErrors(meta._validationSourceErrors)) return false - return childContributionKeys.every( - (key) => meta.childContributionCounts[key] === 0, - ) + return subfieldsMetaKeys.every((key) => meta.childMetaCounts[key] === 0) } -function getErrorsFromBaseMeta( - baseMeta: InternalBaseFieldMeta, - previousMeta?: InternalFieldMeta, -): Array { - let result: Array - if ( - previousMeta?._validationSourceErrors === baseMeta._validationSourceErrors - ) { - result = previousMeta.original.errors - } else { - result = getValidationSourceErrors(baseMeta._validationSourceErrors) - } - return result -} - -export function hasFieldMetaErrors(meta: InternalBaseFieldMeta): boolean { - return ( - hasValidationSourceErrors(meta._validationSourceErrors) || - meta.childContributionCounts.error > 0 - ) +export function hasFieldMetaErrors( + meta: InternalBaseFieldMeta, + hasOwnErrors = false, +): boolean { + return hasOwnErrors || meta.childMetaCounts.error > 0 } diff --git a/packages/form-core/src/FieldApi/fieldTraversal.lib.ts b/packages/form-core/src/FieldApi/fieldTraversal.lib.ts index b87ed9666..d0a1bfc02 100644 --- a/packages/form-core/src/FieldApi/fieldTraversal.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTraversal.lib.ts @@ -1,5 +1,12 @@ -import type { AnyInternalFieldApi } from './FieldApi.lib' +import { devtools } from '../devtoolsBridge.lib' +import type { + AnyFieldApiOptions, + AnyInternalFieldApi, + FieldOptionsScope, +} from './FieldApi.lib' import type { InternalRootFieldApi } from './RootFieldApi.lib' +import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' +import type { NameSegments } from '../utils.lib' const stop = Symbol('stop field traversal') type FieldTraversalStop = typeof stop @@ -9,6 +16,86 @@ type FieldVisitor = ( stop: FieldTraversalStop, ) => void | FieldTraversalStop +/** + * @private + * Get a field api at the specified location. If it doesn't exist, + * it will create the necessary nodes to get it. + * + * @important This mutates the segments array. + */ +export function getOrCreateFieldApi( + node: AnyInternalFieldApi | InternalRootFieldApi, + segments: NameSegments, + form: AnyInternalFormApi, + options?: Omit, + scope: FieldOptionsScope = 'field', +): AnyInternalFieldApi { + const segment = segments.shift() + if (segment === undefined) { + // If trieNode is the root, we need to return a field node, not the root + if (node._isRoot) { + throw new Error('Root node cannot be a field API') + } + // Internal trie nodes defer their options until they are first requested as + // a configured field. Adapter updates handle subsequent option changes. + if (scope !== 'internal' && !node._fieldOptionsInitialized) { + node._update(options ?? {}, scope) + } + return node + } + + let childNode = node._getChild(segment) + if (childNode) { + return getOrCreateFieldApi(childNode, segments, form, options, scope) + } + + childNode = new form._FieldApi( + { + segment, + parent: node, + form: form, + }, + 'internal', + ) + + node._setChild(childNode) + if (segments.length === 0) { + const field = getOrCreateFieldApi(childNode, segments, form, options, scope) + devtools().fieldAdded?.(childNode) + return field + } + + devtools().fieldAdded?.(childNode) + + return getOrCreateFieldApi(childNode, segments, form, options, scope) +} + +/** + * @private + * + * @important This mutates the segments array. + */ +export function tryGetFieldApi( + trieNode: AnyInternalFieldApi | InternalRootFieldApi, + segments: NameSegments, +): AnyInternalFieldApi | null { + const segment = segments.shift() + if (segment === undefined) { + // If trieNode is the root, we cannot return it as a field API + if (!trieNode._isRoot) { + return trieNode + } + return null + } + + const childNode = trieNode._getChild(segment) + if (childNode) { + return tryGetFieldApi(childNode, segments) + } else { + return null + } +} + /** * Visits a field node followed by each of its ancestors, stopping before the * synthetic root node. Return the visitor's `stop` argument to stop the @@ -57,17 +144,22 @@ function visitFields( /** * Visits a field node and its descendants in insertion-order preorder. The - * starting field is included. Return the visitor's `stop` argument to stop the - * entire traversal, not only the current branch. + * starting field is included unless it is the synthetic root. Return the + * visitor's `stop` argument to stop the entire traversal, not only the current + * branch. * * A node's children are read after its visitor runs, so structural mutations * made by the visitor affect which descendants are visited. */ export function visitFieldSubtree( - field: AnyInternalFieldApi, + field: AnyInternalFieldApi | InternalRootFieldApi, visitor: FieldVisitor, ): void { - visitFields([field], visitor) + if (field._isRoot) { + visitFields(field._children, visitor) + } else { + visitFields([field], visitor) + } } /** @@ -82,7 +174,7 @@ export function visitAllFormFields( root: InternalRootFieldApi, visitor: FieldVisitor, ): void { - visitFields(root._children, visitor) + visitFieldSubtree(root, visitor) } /** diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 7cc558f2f..4f9f90461 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -1,261 +1,50 @@ import { batch } from '@tanstack/store' import { devtools } from '../devtoolsBridge.lib' +import { removeFieldWatches } from './linked-fields.lib' import { - detachWatchingListenerField, - detachWatchingValidatorField, -} from './linked-fields.lib' -import { - childContributionKeys, defaultInternalBaseFieldMeta, - getChildContributionStates, + getSubfieldsMetaFlags, isPrunableMeta, + subfieldsMetaKeys, } from './fieldState.lib' import { collectFieldSubtree, visitAllFormFields, visitFieldAndAncestors, } from './fieldTraversal.lib' -import type { FieldListenerTriggers } from '../listeners.public' +import type { FormMetaCountKey, InternalRootFieldApi } from './RootFieldApi.lib' +import type { AnyInternalFieldApi } from './FieldApi.lib' import type { FieldDependencyChange } from '../devtoolsBridge.lib' -import type { - AnyInternalFieldApi, - FieldWatchingListenerFields, - FieldWatchingValidatorFields, -} from './FieldApi.lib' -import type { - InternalRootFieldApi, - RootCounterContributionKey, -} from './RootFieldApi.lib' -import type { ChildContributionStates } from './fieldState.lib' +import type { SubfieldsMetaFlags } from './fieldState.lib' import type { NameSegment } from '../utils.lib' +import type { FieldListenerTriggers } from '../pipeline/listeners/listeners.public' -const rootCounterContributionKeys: Array = [ - 'touched', - 'validating', -] - -function detachWatchedListenerFields({ - field, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, -}: { - field: AnyInternalFieldApi - nodesToKill: Set - fieldsToPruneAfterKill: Set - dependencyChanges: Array | null -}) { - field._listenerInstances?.forEach((listenerInstance) => { - listenerInstance.resolvedWatchFields?.forEach((sourceField) => { - const change = { - kind: 'listener' as const, - sourceField, - watchingField: field, - listenerInstance, - } - detachWatchingListenerField(change, { pruneSourceField: false }) - dependencyChanges?.push(change) - - if (!nodesToKill.has(sourceField)) { - fieldsToPruneAfterKill.add(sourceField) - } - }) - listenerInstance.resolvedWatchFields = null - }) -} - -function detachWatchingListenerFields({ - sourceField, - watchingFields, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, -}: { - sourceField: AnyInternalFieldApi - watchingFields: FieldWatchingListenerFields | null - nodesToKill: Set - fieldsToPruneAfterKill: Set - dependencyChanges: Array | null -}) { - if (!watchingFields) return - - for (const [watchingField, listenerInstances] of Array.from(watchingFields)) { - for (const listenerInstance of Array.from(listenerInstances)) { - const change = { - kind: 'listener' as const, - sourceField, - watchingField, - listenerInstance, - } - detachWatchingListenerField(change, { pruneSourceField: false }) - dependencyChanges?.push(change) - - listenerInstance.resolvedWatchFields?.forEach((resolvedField, name) => { - if (resolvedField === sourceField) { - listenerInstance.deleteResolvedWatchField(name) - } - }) - - if (!nodesToKill.has(watchingField)) { - fieldsToPruneAfterKill.add(watchingField) - } - } - } -} - -/** - * 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, - fieldsToPruneAfterKill, - dependencyChanges, -}: { - field: AnyInternalFieldApi - nodesToKill: Set - fieldsToPruneAfterKill: Set - dependencyChanges: Array | null -}) { - detachWatchedListenerFields({ - field, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, - }) - - detachWatchedValidatorFields({ - field, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, - }) +const formMetaCountKeys: Array = ['touched', 'validating'] - detachWatchingListenerFields({ - sourceField: field, - watchingFields: field._watchingListenerFields, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, - }) - field._watchingListenerFields = null - - detachWatchingValidatorFields({ - sourceField: field, - watchingFields: field._watchingValidatorFields, - nodesToKill, - fieldsToPruneAfterKill, - dependencyChanges, - }) - field._watchingValidatorFields = null -} - -export function updateChildContributionCount( +export function updateChildMetaCounts( field: AnyInternalFieldApi, - prevState: ChildContributionStates, - newState: ChildContributionStates, + previousFlags: SubfieldsMetaFlags, + nextFlags: SubfieldsMetaFlags, ): void { if (field._isKilled) return batch(() => { - for (const key of childContributionKeys) { - const prevContributes = prevState[key] - const newContributes = newState[key] + for (const key of subfieldsMetaKeys) { + const wasSet = previousFlags[key] + const isSet = nextFlags[key] - if (prevContributes === newContributes) continue + if (wasSet === isSet) continue - const delta = newContributes ? 1 : -1 + const delta = isSet ? 1 : -1 field._setMeta((prev) => ({ ...prev, - childContributionCounts: { - ...prev.childContributionCounts, - [key]: prev.childContributionCounts[key] + delta, + childMetaCounts: { + ...prev.childMetaCounts, + // In DFS that resets fields, the parent could potentially forget + // about the child count. Unlikely to happen during normal runtime, + // but this would be a nasty footgun and Math.max is probably cheap. + [key]: Math.max(0, prev.childMetaCounts[key] + delta), }, })) } @@ -289,32 +78,18 @@ function notifyFieldSubtreeListeners( for (const node of collectFieldSubtree(field)) { if (node._isKilled) continue - node._notifyListener(trigger, new WeakSet()) + node._notifyListener({ trigger, seenFields: new WeakSet() }) } } function prepareFormGroupsForFieldReplacement( fields: ReadonlyArray, ): () => void { - const fieldsToReplace = new Set(fields) const formGroups = fields.flatMap((field) => field._formGroup ? [{ group: field._formGroup, name: field.name }] : [], ) - const affectedFormGroups = new Set(formGroups.map(({ group }) => group)) - - const replacementRoot = fields[0] - if (replacementRoot) { - visitFieldAndAncestors(replacementRoot, (field) => { - if (field._formGroup) affectedFormGroups.add(field._formGroup) - }) - } - - for (const group of affectedFormGroups) { - group._removeRoutedErrorFields(fieldsToReplace) - } - for (const { group } of formGroups) { - group._cancelValidation() + group._groupField._cancelValidation() } return () => { @@ -337,7 +112,7 @@ export function killField( const bridge = devtools() const dependencyChanges = bridge.fieldDependenciesChanged ? new Array() - : null + : undefined let removedFields: Array<{ field: AnyInternalFieldApi previousPath: string @@ -346,12 +121,11 @@ export function killField( batch(() => { const nodesToKill = collectFieldSubtree(field) + field.form._cancelFieldValidation(field.name) removedFields = nodesToKill.map((node) => ({ field: node, previousPath: node.name, })) - const nodesToKillSet = new Set(nodesToKill) - const fieldsToPruneAfterKill = new Set() reattachFormGroups = prepareFormGroupsForFieldReplacement(nodesToKill) @@ -361,29 +135,19 @@ export function killField( field._parent._removeChild(field._segment) - const killedRootCounterContributions: Record< - RootCounterContributionKey, - number - > = { + const removedRootFieldCounts: Record = { touched: 0, validating: 0, } for (const node of nodesToKill) { + node._resetValidation() const nodeMeta = node._atoms.meta?.get() - nodeMeta?._validationSourceErrors?.forEach((_error, validationSource) => - validationSource.deleteErrorTarget(node), - ) - detachLinkedFieldReferences({ - field: node, - nodesToKill: nodesToKillSet, - fieldsToPruneAfterKill, - dependencyChanges, - }) + removeFieldWatches(node, dependencyChanges) if (!node._parent._isRoot && nodeMeta) { - node._parent._updateChildContributionCount( - getChildContributionStates(nodeMeta), + node._parent._updateChildMetaCounts( + getSubfieldsMetaFlags(nodeMeta, node._errorStore.hasErrors), { dirty: false, error: false, @@ -392,10 +156,13 @@ export function killField( }, ) } else if (nodeMeta) { - const contributions = getChildContributionStates(nodeMeta) - for (const key of rootCounterContributionKeys) { - if (contributions[key]) { - killedRootCounterContributions[key]++ + const flags = getSubfieldsMetaFlags( + nodeMeta, + node._errorStore.hasErrors, + ) + for (const key of formMetaCountKeys) { + if (flags[key]) { + removedRootFieldCounts[key]++ } } } @@ -413,15 +180,11 @@ export function killField( node._parent._removeChild(node._segment) } - for (const fieldToPrune of fieldsToPruneAfterKill) { - pruneFieldIfUnused(fieldToPrune) - } - field.form._atoms.meta.touchedFieldCount.set((prev) => - Math.max(0, prev - killedRootCounterContributions.touched), + Math.max(0, prev - removedRootFieldCounts.touched), ) field.form._atoms.meta.fieldValidationCount.set((prev) => - Math.max(0, prev - killedRootCounterContributions.validating), + Math.max(0, prev - removedRootFieldCounts.validating), ) field.form._atoms.meta.errorFields.set((prev) => { @@ -439,15 +202,15 @@ export function killField( return prev }) - }) - if (removedFields.length > 0) { - bridge.removeFieldSubtree?.(field.form, removedFields) - } - if (dependencyChanges && dependencyChanges.length > 0) { - bridge.fieldDependenciesChanged?.(dependencyChanges) - } - reattachFormGroups() + if (removedFields.length > 0) { + bridge.removeFieldSubtree?.(field.form, removedFields) + } + if (dependencyChanges && dependencyChanges.length > 0) { + bridge.fieldDependenciesChanged?.(dependencyChanges) + } + reattachFormGroups() + }) } function canPruneField(field: AnyInternalFieldApi): boolean { @@ -456,16 +219,8 @@ function canPruneField(field: AnyInternalFieldApi): boolean { if (field._refCount > 0) return false if (field._formGroup) return false if (field._childrenMap.size > 0) return false - if (field._watchingListenerFields) return false - if (field._watchingValidatorFields) return false - // 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._listenerInstances?.some((v) => v.resolvedWatchFields)) { - return false - } - if (field._validatorInstances?.some((v) => v.resolvedWatchFields)) { - return false - } + if (field._watchedListenerNames || field._watchedValidatorNames) return false + if (field._errorStore.hasErrors) return false const meta = field._atoms.meta?.get() ?? defaultInternalBaseFieldMeta if (!isPrunableMeta(meta)) return false @@ -490,29 +245,3 @@ export function pruneFieldIfUnused(field: AnyInternalFieldApi): void { bridge.removeFieldSubtree?.(field.form, removedFields) } } - -export function touchAllFieldsAndCollectSubmitValidators( - root: InternalRootFieldApi, -): Array { - const fieldsWithValidators: Array = [] - visitAllFormFields(root, (field) => { - field._notifyEvent( - { - causeValidation: false, - markAsBlurred: false, - markAsDirty: false, - // Touch all fields - markAsTouched: true, - // We're doing DFS, so propagation is useless - doPropagate: false, - }, - 'submit', - ) - - if (field._validatorInstances && field._validatorInstances.length > 0) { - fieldsWithValidators.push(field) - } - }) - - return fieldsWithValidators -} diff --git a/packages/form-core/src/FieldApi/linked-fields.lib.ts b/packages/form-core/src/FieldApi/linked-fields.lib.ts index 1ce21642f..25ce886c7 100644 --- a/packages/form-core/src/FieldApi/linked-fields.lib.ts +++ b/packages/form-core/src/FieldApi/linked-fields.lib.ts @@ -1,237 +1,110 @@ -import type { - AnyInternalFieldApi, - InternalFieldListenerInstance, - InternalFieldValidatorInstance, -} from './FieldApi.lib' -import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from './FieldApi.lib' +import type { FieldDependencyChange } from '../devtoolsBridge.lib' -export interface ListenerWatchFieldOperation { - kind: 'listener' - sourceField: AnyInternalFieldApi - watchingField: AnyInternalFieldApi - listenerInstance: InternalFieldListenerInstance -} - -export interface ValidatorWatchFieldOperation { - kind: 'validator' - sourceField: AnyInternalFieldApi - watchingField: AnyInternalFieldApi - validatorInstance: InternalFieldValidatorInstance -} +export type FieldWatchRegistry = Map> +type WatchKind = 'listener' | 'validator' -export interface DetachWatchingFieldOptions { - pruneSourceField?: boolean +export function watchesField( + watchFields: ReadonlyArray | undefined, + name: string, +): boolean { + return watchFields?.includes(name) ?? false } -export function reconcileWatchedListenerFields({ - listenerInstances, - field, - form, -}: { - listenerInstances: ReadonlyArray | null - field: AnyInternalFieldApi - form: AnyInternalFormApi -}): { - attach: Array - detach: Array -} { - const attach: Array = [] - const detach: Array = [] - - listenerInstances?.forEach((listenerInstance) => { - const previous = listenerInstance.resolvedWatchFields - const next = new Map() - const names = [...new Set(listenerInstance.definition.watchFields ?? [])] - - for (const name of names) { - const sourceField = form._getOrCreateFieldApi({ name }, 'internal') - next.set(name, sourceField) - - const previousField = previous?.get(name) - if (previousField === sourceField) continue - - if (previousField) { - detach.push({ - kind: 'listener', - sourceField: previousField, - watchingField: field, - listenerInstance, - }) - } - - attach.push({ - kind: 'listener', - sourceField, - watchingField: field, - listenerInstance, - }) +function unregisterDroppedWatches( + field: AnyInternalFieldApi, + registry: FieldWatchRegistry, + previous: Set | null, + next: Set, + changes?: Array, +): void { + for (const name of previous ?? []) { + if (next.has(name)) continue + + const watchingFields = registry.get(name) + watchingFields?.delete(field) + if (watchingFields?.size === 0) { + registry.delete(name) } - previous?.forEach((sourceField, name) => { - if (next.has(name)) return - - detach.push({ - kind: 'listener', - sourceField, - watchingField: field, - listenerInstance, - }) - }) - - listenerInstance.resolvedWatchFields = next.size > 0 ? next : null - }) - - return { attach, detach } + changes?.push({ sourceFieldName: name, watchingField: field }) + } } -export function reconcileWatchedValidatorFields({ - validatorInstances, - field, - form, -}: { - validatorInstances: ReadonlyArray | null - field: AnyInternalFieldApi - form: AnyInternalFormApi -}): { - 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 }, 'internal') - 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, - }) +function registerCurrentWatches( + field: AnyInternalFieldApi, + registry: FieldWatchRegistry, + previous: Set | null, + next: Set, + changes?: Array, +): void { + for (const name of next) { + // Refresh devtools: a retained path can be watched by different listener or + // validator entries after an options update. + changes?.push({ sourceFieldName: name, watchingField: field }) + if (previous?.has(name)) continue + + let watchingFields = registry.get(name) + if (!watchingFields) { + watchingFields = new Set() + registry.set(name, watchingFields) } - - 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 } -} - -export function attachWatchingListenerField({ - sourceField, - watchingField, - listenerInstance, -}: ListenerWatchFieldOperation) { - let watchingFields = sourceField._watchingListenerFields - if (!watchingFields) { - watchingFields = new Map() - sourceField._watchingListenerFields = watchingFields - } - - let instances = watchingFields.get(watchingField) - if (!instances) { - instances = new Set() - watchingFields.set(watchingField, instances) + watchingFields.add(field) } - - instances.add(listenerInstance) } -export function detachWatchingListenerField( - { sourceField, watchingField, listenerInstance }: ListenerWatchFieldOperation, - options: DetachWatchingFieldOptions = {}, +function replaceListenerRegistrations( + field: AnyInternalFieldApi, + next: Set, + changes?: Array, ) { - const watchingFields = sourceField._watchingListenerFields - if (!watchingFields) return - - const instances = watchingFields.get(watchingField) - if (!instances) return - - instances.delete(listenerInstance) + const registry = field.form._watchingListenerFields + const previous = field._watchedListenerNames - if (instances.size === 0) { - watchingFields.delete(watchingField) - if (watchingFields.size === 0) { - sourceField._watchingListenerFields = null - } - } + unregisterDroppedWatches(field, registry, previous, next, changes) + registerCurrentWatches(field, registry, previous, next, changes) - if (options.pruneSourceField !== false) { - sourceField._pruneIfUnused() - } + field._watchedListenerNames = next.size > 0 ? next : null } -export function attachWatchingValidatorField({ - sourceField, - watchingField, - validatorInstance, -}: ValidatorWatchFieldOperation) { - let watchingFields = sourceField._watchingValidatorFields - if (!watchingFields) { - watchingFields = new Map() - sourceField._watchingValidatorFields = watchingFields - } +function replaceValidatorRegistrations( + field: AnyInternalFieldApi, + next: Set, + changes?: Array, +) { + const registry = field.form._watchingValidatorFields + const previous = field._watchedValidatorNames - let instances = watchingFields.get(watchingField) - if (!instances) { - instances = new Set() - watchingFields.set(watchingField, instances) - } + unregisterDroppedWatches(field, registry, previous, next, changes) + registerCurrentWatches(field, registry, previous, next, changes) - instances.add(validatorInstance) + field._watchedValidatorNames = next.size > 0 ? next : null } -export function detachWatchingValidatorField( - { - sourceField, - watchingField, - validatorInstance, - }: ValidatorWatchFieldOperation, - options?: DetachWatchingFieldOptions, -) { - 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 +export function updateFieldWatches( + field: AnyInternalFieldApi, + kind: WatchKind, + changes?: Array, +): void { + const instances = + kind === 'listener' ? field._listenerInstances : field._validatorInstances + const names = new Set() + for (const instance of instances ?? []) { + for (const name of instance.definition.watchFields ?? []) { + names.add(name) } } - - if (options?.pruneSourceField !== false) { - sourceField._pruneIfUnused() + if (kind === 'listener') { + replaceListenerRegistrations(field, names, changes) + } else { + replaceValidatorRegistrations(field, names, changes) } } + +export function removeFieldWatches( + field: AnyInternalFieldApi, + changes?: Array, +): void { + replaceListenerRegistrations(field, new Set(), changes) + replaceValidatorRegistrations(field, new Set(), changes) +} diff --git a/packages/form-core/src/FieldGroup/FieldGroupApi.lib.ts b/packages/form-core/src/FieldGroup/FieldGroupApi.lib.ts index 8cdd00abd..1f45c3a0a 100644 --- a/packages/form-core/src/FieldGroup/FieldGroupApi.lib.ts +++ b/packages/form-core/src/FieldGroup/FieldGroupApi.lib.ts @@ -1,11 +1,11 @@ import { createAtom, shallow } from '@tanstack/store' -import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' import { getBy } from '../utils.lib' -import type { AnyFieldApiOptions } from '../FieldApi/FieldApi.lib' +import { transformFieldOptionsFieldNames } from '../FieldApi/fieldOptions.lib' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' -import type { InternalFieldUpdateOptions } from '../types.lib' -import type { ReadonlyAtom } from '@tanstack/store' import type { FieldGroupApi } from './FieldGroupApi.public' +import type { ReadonlyAtom } from '@tanstack/store' +import type { AnyFieldApiOptions } from '../FieldApi/FieldApi.lib' +import type { InternalFieldUpdateOptions } from '../types.lib' export type InternalFieldGroupBindings = Record diff --git a/packages/form-core/src/FieldGroup/FieldGroupApi.public.ts b/packages/form-core/src/FieldGroup/FieldGroupApi.public.ts index bf8e45f44..b0d35c6e9 100644 --- a/packages/form-core/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/form-core/src/FieldGroup/FieldGroupApi.public.ts @@ -1,6 +1,6 @@ -import type { ReadonlyAtom } from '@tanstack/store' -import type { FormApiArrayMethods } from '../FormApi/FormApiArrayMethods.types.public' import type { FormApiFieldMethods } from '../FormApi/FormApiFieldMethods.types.public' +import type { FormApiArrayMethods } from '../FormApi/FormApiArrayMethods.types.public' +import type { ReadonlyAtom } from '@tanstack/store' export interface FieldGroupApi extends FormApiFieldMethods, FormApiArrayMethods { diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 4d0a90bd3..a7c14fc3c 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -1,57 +1,52 @@ -import { batch, createAtom } from '@tanstack/store' -import { - InternalFieldApi, - getDefaultValueCacheResult, - getOrCreateFieldApi, - shouldCacheDefaultValue, - tryGetFieldApi, -} from '../FieldApi/FieldApi.lib' +import { batch } from '@tanstack/store' +import { handleFieldChangeWithoutCreatingField } from '../FieldApi/fieldEvents.lib' import { callUpdater, evaluate, getBy, getTargetField, + isNotNil, nameToFieldNodeSegments, resolveFieldUpdateOptions, setBy, uuid, } from '../utils.lib' -import { InternalRootFieldApi } from '../FieldApi/RootFieldApi.lib' +import { devtools } from '../devtoolsBridge.lib' import { collectFieldSubtree, + getOrCreateFieldApi, + tryGetFieldApi, visitAllFormFields, visitFieldAndAncestors, } from '../FieldApi/fieldTraversal.lib' +import { + InternalFieldApi, + getDefaultValueCacheResult, + shouldCacheDefaultValue, +} from '../FieldApi/FieldApi.lib' +import { ValidationRunTracker } from '../pipeline/runTracker.lib' +import { ListenerInstance } from '../pipeline/listeners/ListenerInstance.lib' +import { ValidatorInstance } from '../pipeline/validation/ValidatorInstance.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' +import { startFormValidation } from '../pipeline/validation/ownerValidation.lib' +import { + removeSourceErrors, + replaceSourceErrors, + validationSourceScopes, +} from '../validation/errorStorage.lib' import { defaultInternalBaseFieldMeta } from '../FieldApi/fieldState.lib' +import { runFormListenerPipeline } from '../pipeline/listeners/listeners.lib' import { - clearValidationSourceErrorsFromEvent, - isErrorResult, - isValidationTriggerEnabled, + getValidationErrors, parseValidationResult, - reconcileRoutedFieldErrors, - runFormMountValidatorPipeline, - runFormValidatorPipeline, - setValidationSourceError, -} from '../validation' -import { runFormListenerPipeline } from '../listeners.lib' -import { applyServerState } from '../ssr.lib' -import { devtools } from '../devtoolsBridge.lib' -import { reconcileValidatorInstances } from '../ValidatorInstance.lib' -import { reconcileListenerInstances } from '../ListenerInstance.lib' -import { InternalValidationSourceInstance } from '../ValidationSourceInstance.lib' -import { resolveDefaultOptions } from '../defaultOptions.lib' -import { runSubmissionProcess } from './handleSubmit.lib' +} from '../validation/results/errors.lib' +import { updateFormOptions } from './formOptions.lib' +import { initializeForm } from './initializeForm.lib' import { ArrayMethods } from './array-methods.lib' -import { - clearFormValidationSourceErrorsFromEvent, - compareFormStateSnapshots, - getFormStateSnapshot, - reconcileFormErrorFields, -} from './formState.lib' -import type { FormApi, FormResetOptions, FormState } from './FormApi.public' -import type { FormErrorMeta } from './formState.lib' -import type { DeepKeys } from '../deep-keys.public' -import type { FormValidatorPipelineResult, PipelineResult } from '../validation' +import { handleFormSubmit } from './handleSubmit.lib' +import type { Atom, ReadonlyAtom } from '@tanstack/store' +import type { InternalRootFieldApi } from '../FieldApi/RootFieldApi.lib' +import type { FieldWatchRegistry } from '../FieldApi/linked-fields.lib' import type { AnyFieldApiOptions, AnyInternalFieldApi, @@ -59,64 +54,54 @@ import type { FieldOptionsScope, } from '../FieldApi/FieldApi.lib' import type { - InternalBaseFieldMeta, - InternalFieldMeta, -} from '../FieldApi/fieldState.lib' -import type { - FieldApiOverrideOptions, - InternalFieldUpdateOptions, - ResolvedInternalFieldUpdateOptions, -} from '../types.lib' -import type { Atom, ReadonlyAtom } from '@tanstack/store' -import type { Updater } from '../types.public' -import type { InternalListenerInstances } from '../ListenerInstance.lib' -import type { - InternalValidatorInstance, - InternalValidatorInstances, -} from '../ValidatorInstance.lib' -import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' + ClientSideEvents, + ValidatorEvents, +} from '../pipeline/events.public' +import type { FormListenerTriggers } from '../pipeline/listeners/formListeners.public' +import type { FormOptionsOfType, InternalFormOptions } from './formOptions.lib' +import type { FormApi, FormResetOptions, FormState } from './FormApi.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { DefaultOptions } from '../defaultOptions.public' +import type { ValidatorInstances } from '../pipeline/validation/ValidatorInstance.lib' +import type { ListenerInstances } from '../pipeline/listeners/ListenerInstance.lib' +import type { ServerFormState } from './formServerState.public' import type { - FormValidationFailure, FormValidationResult, - ScopedErrorTypes, + FormattedValidationError, ValidationIssue, -} from '../validation/state.public' -import type { FormOptions } from './formOptions.public' -import type { - FormOptionsOnSubmitFn, - FormOptionsOnSubmitInvalidFn, -} from './formSubmit.public' -import type { FormValidator } from '../validation/formValidators.public' -import type { StandardSchemaV1 } from '../standardSchema.public' -import type { ServerFormState } from './formServerState.public' +} from '../validation/results/results.public' +import type { ClientValidationRun } from '../pipeline/validation/clientValidation.lib' +import type { FormValidationOptions } from '../pipeline/validation/ownerValidation.lib' +import type { InternalFieldMeta } from '../FieldApi/fieldState.lib' +import type { Updater } from '../types.public' import type { - ClientSideEvents, - DynamicValidatorTriggers, - ValidatorTriggers, -} from '../events.public' + FieldApiOverrideOptions, + InternalFieldUpdateOptions, + ResolvedInternalFieldUpdateOptions, +} from '../types.lib' +import type { DeepKeys } from '../deep-keys.public' import type { - AnyFormListener, - FormListenerTriggers, - FormListeners, -} from './formListeners.public' + OnSubmitErrorSource, + ValidationErrorSource, + ValidationErrorTarget, +} from '../validation/errorStorage.lib' +import type { SubmissionAttempt } from '../submissionAttempt.lib' export interface FormMetaAtoms { isDirty: Atom /** * @private - * Number of root fields whose own or descendant meta currently contributes touched state. + * Number of root fields that are touched or have a touched descendant. */ touchedFieldCount: Atom - formErrors: Atom /** * @private - * Root fields whose own or descendant meta currently contributes errors. + * Root fields that have errors on themselves or their descendants. */ errorFields: Atom> /** * @private - * Number of root fields whose own or descendant meta currently validates. + * Number of root fields that are validating or have a validating descendant. */ fieldValidationCount: Atom validationCount: Atom @@ -132,79 +117,8 @@ export interface FormAtoms { defaultValuesVersion: Atom } -function createInitialFormErrorMeta(): FormErrorMeta { - return { - validationSourceErrors: null, - } -} - -function createInitialFormMetaAtoms(): FormMetaAtoms { - return { - isDirty: createAtom(false), - touchedFieldCount: createAtom(0), - formErrors: createAtom(createInitialFormErrorMeta()), - errorFields: createAtom(new Set()), - fieldValidationCount: createAtom(0), - validationCount: createAtom(0), - isSubmitting: createAtom(false), - isSubmitSuccessful: createAtom(false), - submissionAttempts: createAtom(0), - } -} - export type AnyInternalFormApi = InternalFormApi -export type InternalFormValidatorInstance = InternalValidatorInstance< - 'form', - AnyInternalFormApi, - AnyInternalFieldApi -> - -type InternalFormValidator = Omit< - FormValidator, - 'run' -> & { - run: StandardSchemaV1 | ((context: any) => any) -} - -// Funky form options are cool and all, but just using `any` killed our type inference internally. -// This is hacky, but it should do. -type FormOptionsOfType = Omit< - FormOptions, - 'validators' | 'listeners' | 'onSubmit' | 'onSubmitInvalid' -> & { - validators?: ReadonlyArray>> - listeners?: FormListeners, any> - onSubmit?: FormOptionsOnSubmitFn, any, any> - onSubmitInvalid?: FormOptionsOnSubmitInvalidFn, any> -} -type InternalFormOptions = FormOptionsOfType & { - formId: string -} - -// This scales with the amount of top-level fields, with the setter operation -// scaling with the amount of touched fields. - -// It assumes that by the time async defaultValues come in, not many fields have been touched. -// If they were, they're likely in the same section. -function applyDefaultValuesPreservingTouchedFields( - currentValues: TFormData, - defaultValues: TFormData, - form: AnyInternalFormApi, -): TFormData { - let nextValues = defaultValues - - for (const field of form._fieldRootNode._children) { - if (field.meta.isTouched) { - nextValues = setBy( - nextValues, - field.name, - getBy(currentValues, field.name), - ) - } - } - - return nextValues -} +export type InternalFormValidatorInstance = ValidatorInstance<'form'> function notifyDevtoolsFieldValueUpdate( field: AnyInternalFieldApi | null | undefined, @@ -215,17 +129,12 @@ function notifyDevtoolsFieldValueUpdate( visitFieldAndAncestors(field, (current) => updateField(current)) } -function notifyDevtoolsDefaultValuesUpdate(form: AnyInternalFormApi): void { - const updateField = devtools().updateField - if (!updateField) return - - visitAllFormFields(form._fieldRootNode, (current) => updateField(current)) -} - export class InternalFormApi implements FormApi< TFormData, ScopedErrorTypes > { + readonly _watchingListenerFields: FieldWatchRegistry = new Map() + readonly _watchingValidatorFields: FieldWatchRegistry = new Map() /** * Devtools use this to show what version of the library is being used. */ @@ -242,23 +151,28 @@ export class InternalFormApi implements FormApi< readonly _defaultOptions: DefaultOptions | undefined _options: InternalFormOptions /** Stable runtime instances correlated with `_options.validators` by slot. */ - _validatorInstances: InternalValidatorInstances< - 'form', - AnyInternalFormApi, - AnyInternalFieldApi - > + _validatorInstances: ValidatorInstances<'form'> = null /** Stable source for errors returned directly by the form's `onSubmit`. */ - _onSubmitSource: InternalValidationSourceInstance< - AnyInternalFormApi, - AnyInternalFieldApi - > + _onSubmitSource: OnSubmitErrorSource _lastUpdateDefaultValues: TFormData /** Stable runtime instances correlated with `_options.listeners` by slot. */ - _listenerInstances: InternalListenerInstances< - AnyFormListener, - AnyInternalFormApi - > + _listenerInstances: ListenerInstances<'form'> = null _lastServerState: ServerFormState | null = null + private readonly _validationRuns = new ValidationRunTracker() + _submissions = new Set< + SubmissionAttempt> + >() + + _resetSubmissions(): void { + const submissions = Array.from(this._submissions) + this._submissions.clear() + for (const submission of submissions) submission.invalidate() + this._atoms.meta.isSubmitting.set(false) + this._atoms.meta.isSubmitSuccessful.set(false) + visitAllFormFields(this._fieldRootNode, (field) => + field._formGroup?._resetSubmissions(), + ) + } get state(): FormState { return this.atom.get() @@ -328,45 +242,16 @@ export class InternalFormApi implements FormApi< formId: resolvedOptions.formId ?? uuid(), } this._lastUpdateDefaultValues = resolvedOptions.defaultValues - this._atoms = { - values: createAtom(resolvedOptions.defaultValues), - meta: createInitialFormMetaAtoms(), - resetVersion: createAtom(0), - defaultValuesVersion: createAtom(0), - } - this._fieldRootNode = new InternalRootFieldApi(this) - this._onSubmitSource = new InternalValidationSourceInstance({ - owner: this, - scope: 'onSubmit', - }) + this._lastServerState = this._options.serverState ?? null + this._updateInstances() - this.atom = createAtom(() => getFormStateSnapshot(this), { - compare: compareFormStateSnapshots, - }) - this._listenerInstances = reconcileListenerInstances({ - definitions: this._options.listeners, - instances: null, - owner: this, - }) - this._validatorInstances = reconcileValidatorInstances< - 'form', - AnyInternalFormApi, - AnyInternalFieldApi - >({ - definitions: this._options.validators, - instances: null, - owner: this, - scope: 'form', - onBeforeDispose: (validatorInstance) => - this._removeValidatorInstance(validatorInstance), - }) + const initial = initializeForm(this, this._validationRuns) - applyServerState( - this, - this._options.serverState ?? null, - resolvedOptions.defaultValues, - ) - this._runMountValidation() + this._atoms = initial.atoms + this._fieldRootNode = initial.fieldRootNode + this._onSubmitSource = initial.onSubmitSource + this.atom = initial.atom + initial.complete() } mount = () => { @@ -378,22 +263,17 @@ export class InternalFormApi implements FormApi< return () => { if (didCleanup) return didCleanup = true + batch(() => this._resetSubmissions()) devtools().unmountForm?.(this) } } - _clearFormValidationSource(sourceEvent: string): void { - const validationSources = this._validatorInstances ?? [] - - clearFormValidationSourceErrorsFromEvent({ - formErrors: this._atoms.meta.formErrors, - errorFields: this._atoms.meta.errorFields, - validationSources, - sourceEvent, - fieldScope: { type: 'all' }, - clearFieldEventErrors: (field, instances, eventSource) => - this._clearFieldEventErrors(field, instances, eventSource), - reconcileErrorFields: true, + _clearFormValidationSource(sourceEvent: ValidatorEvents): void { + batch(() => { + for (const source of this._validatorInstances ?? []) { + if (source.lastAppliedEvent === sourceEvent) + this._pruneErrorTargets(removeSourceErrors(source)) + } }) } @@ -405,12 +285,13 @@ export class InternalFormApi implements FormApi< this._options = { ...this._options, defaultValues: values } } - this._listenerInstances?.forEach((instance) => instance.resetRuntime()) - this._validatorInstances?.forEach((instance) => instance.resetRuntime()) - this._onSubmitSource.resetRuntime() - this._defaultValueCache = null - batch(() => { + this._resetSubmissions() + this._cancelValidation() + this._listenerInstances?.forEach((instance) => instance.resetRuntime()) + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) + this._onSubmitSource.resetRuntime() + this._defaultValueCache = null this._atoms.resetVersion.set((version) => version + 1) if (shouldUpdateDefaultValues) { this._atoms.defaultValuesVersion.set((version) => version + 1) @@ -420,7 +301,7 @@ export class InternalFormApi implements FormApi< ) this._atoms.meta.isDirty.set(false) this._atoms.meta.touchedFieldCount.set(0) - this._atoms.meta.formErrors.set(createInitialFormErrorMeta()) + this._fieldRootNode._clearErrors() this._atoms.meta.errorFields.set(new Set()) this._atoms.meta.fieldValidationCount.set(0) this._atoms.meta.validationCount.set(0) @@ -434,71 +315,36 @@ export class InternalFormApi implements FormApi< } _update(options: FormOptionsOfType) { - const resolvedOptions = resolveDefaultOptions( - options, - this._defaultOptions?.form, - ) - const oldOptions = this._options - const didDefaultValuesChange = !evaluate( - resolvedOptions.defaultValues, - this._lastUpdateDefaultValues, - ) + updateFormOptions(this, options) + } - this._lastUpdateDefaultValues = resolvedOptions.defaultValues - this._defaultValueCache = null - this._options = { - ...resolvedOptions, - defaultValues: didDefaultValuesChange - ? resolvedOptions.defaultValues - : oldOptions.defaultValues, - formId: resolvedOptions.formId ?? oldOptions.formId, - } + _updateInstances( + previousOptions?: Pick< + InternalFormOptions, + 'validators' | 'listeners' + >, + ): void { + const previousListeners = previousOptions?.listeners ?? null + const previousValidators = previousOptions?.validators ?? null - this._listenerInstances = reconcileListenerInstances({ + this._listenerInstances = ListenerInstance.update({ + scope: 'form', definitions: this._options.listeners, - previousDefinitions: oldOptions.listeners ?? null, + previousDefinitions: previousListeners, instances: this._listenerInstances, - owner: this, }) - this._validatorInstances = reconcileValidatorInstances< - any, - AnyInternalFormApi, - AnyInternalFieldApi - >({ + this._validatorInstances = ValidatorInstance.update({ + scope: 'form', definitions: this._options.validators, - previousDefinitions: oldOptions.validators ?? null, + previousDefinitions: previousValidators, instances: this._validatorInstances, owner: this, - scope: 'form', - onBeforeDispose: (validatorInstance) => - this._removeValidatorInstance(validatorInstance), + onBeforeDispose: (instance) => { + const affectedTargets = removeSourceErrors(instance) + this._pruneErrorTargets(affectedTargets) + }, }) - - if (didDefaultValuesChange) { - batch(() => { - this._atoms.defaultValuesVersion.set((version) => version + 1) - if (this._atoms.meta.touchedFieldCount.get() === 0) { - this._atoms.values.set(resolvedOptions.defaultValues) - } else { - this._atoms.values.set((prev) => - applyDefaultValuesPreservingTouchedFields( - prev, - resolvedOptions.defaultValues, - this, - ), - ) - } - }) - } - - applyServerState( - this, - this._options.serverState ?? null, - resolvedOptions.defaultValues, - ) - if (didDefaultValuesChange) notifyDevtoolsDefaultValuesUpdate(this) - devtools().updateForm?.(this) } getFieldValue = (fieldName: string): any => { @@ -514,6 +360,20 @@ export class InternalFormApi implements FormApi< updater: Updater, options?: InternalFieldUpdateOptions, ) => { + this._setFieldValue({ fieldName, updater, options }) + } + + _setFieldValue({ + fieldName, + updater, + options, + updateFieldStructure, + }: { + fieldName: string + updater: Updater + options?: InternalFieldUpdateOptions + updateFieldStructure?: () => void + }): void { const updateOptions = resolveFieldUpdateOptions(options, 'change') const field = getTargetField(this, fieldName, updateOptions) @@ -529,6 +389,10 @@ export class InternalFormApi implements FormApi< previousValue.length === nextValue.length this._atoms.values.set((prev) => setBy(prev, fieldName, () => nextValue)) + // Same-value writes shouldn't cancel validation + if (!Object.is(previousValue, nextValue)) { + this._cancelFieldValidation(fieldName) + } if (field && replacedSameLengthArray) { field._setMeta((prev) => ({ @@ -537,7 +401,9 @@ export class InternalFormApi implements FormApi< })) } - this._notifyFieldChange(field, updateOptions) + // Array mutations must finish removing and moving fields before user code runs. + updateFieldStructure?.() + this._notifyFieldChange(field, updateOptions, fieldName) }) notifyDevtoolsFieldValueUpdate(field) @@ -554,10 +420,12 @@ export class InternalFormApi implements FormApi< this._atoms.values.set((prev) => setBy(prev, fieldName, getBy(this.defaultValues, fieldName)), ) + this._cancelFieldValidation(fieldName) for (let index = fields.length - 1; index >= 0; index--) { const current = fields[index]! + current._resetValidation() current._defaultValueCache = null current._listenerInstances?.forEach((instance) => instance.resetRuntime(), @@ -570,12 +438,44 @@ export class InternalFormApi implements FormApi< }) for (const current of fields) { - current._notifyListener('reset', new WeakSet()) + current._notifyListener({ trigger: 'reset', seenFields: new WeakSet() }) } notifyDevtoolsFieldValueUpdate(field) } + _cancelValidation(): void { + batch(() => this._validationRuns.cancelAll()) + } + + /** Cancels owners whose values changed during a bulk update. */ + _cancelChangedValidation(previousValues: TFormData): void { + if (Object.is(previousValues, this._atoms.values.get())) return + batch(() => { + this._cancelValidation() + visitAllFormFields(this._fieldRootNode, (field) => { + if (Object.is(getBy(previousValues, field.name), field.value)) return + field._cancelValidation() + }) + }) + } + + /** Cancels owners along a written value's path without creating nodes. */ + _cancelFieldValidation(fieldName: string): void { + batch(() => { + this._cancelValidation() + let current: AnyInternalFieldApi | InternalRootFieldApi = + this._fieldRootNode + for (const segment of nameToFieldNodeSegments(fieldName)) { + const child: AnyInternalFieldApi | undefined = + current._getChild(segment) + if (!child) break + child._cancelValidation() + current = child + } + }) + } + // TODO type safety: DeepKeys that extend undefined? deleteField = (fieldName: string, opts?: FieldApiOverrideOptions) => { const field = opts?.fieldApiOverride ?? this._tryGetFieldApi(fieldName) @@ -692,20 +592,17 @@ export class InternalFormApi implements FormApi< _notifyFieldChange( field: AnyInternalFieldApi | null, options: ResolvedInternalFieldUpdateOptions, + fieldName: string, ) { - this._clearEventErrors(field, 'submit', 'change') - this._clearEventErrors(field, 'server', 'change') - this._clearEventErrors(field, 'mount', 'change') - const { markAsDirty } = options if (markAsDirty && !this._atoms.meta.isDirty.get()) { this._atoms.meta.isDirty.set(true) } if (field) { - field._notifyEvent(options, 'change') - } else if (options.causeValidation) { - void this.validate('change') + field._handleEvent(options, 'change') + } else { + handleFieldChangeWithoutCreatingField(this, fieldName, options) } this._notifyFormListener('change', field) } @@ -726,56 +623,28 @@ export class InternalFormApi implements FormApi< }) } - _clearEventErrors( + _clearStaleErrors( field: AnyInternalFieldApi | null, - sourceEvent: string, - event: Exclude, - ) { - const validatorInstancesToClear: Array = [] - - for (const validatorInstance of this._validatorInstances ?? []) { - const validator = validatorInstance.definition - - const runsOnEvent = validator.triggers?.some((trigger) => - isValidationTriggerEnabled(trigger, { - scope: 'form', - event, - formApi: this as never, - triggerFieldApi: field ?? undefined, - }), - ) - - if (!runsOnEvent) { - validatorInstancesToClear.push(validatorInstance) - } - } - - if (sourceEvent === 'submit') { - this._clearSubmitErrors(field) + causeValidation: boolean, + canClearSource?: (source: ValidationErrorSource) => boolean, + ): void { + const group = field?._getFormGroup() + const canClear = (source: ValidationErrorSource) => { + if (canClearSource) return canClearSource(source) + if (!causeValidation || source.scope === validationSourceScopes.onSubmit) + return true + // Active owners clear unselected sources after evaluating their predicates. + if (source.scope === validationSourceScopes.field) return false + return source.scope === validationSourceScopes.group + ? source.owner !== group + : isNotNil(group) } - - clearFormValidationSourceErrorsFromEvent({ - formErrors: this._atoms.meta.formErrors, - errorFields: this._atoms.meta.errorFields, - validationSources: validatorInstancesToClear, - sourceEvent, - fieldScope: field ? { type: 'field', field } : { type: 'none' }, - clearFieldEventErrors: (targetField, instances, eventSource) => - this._clearFieldEventErrors(targetField, instances, eventSource), - }) - } - - _clearSubmitErrors(field: AnyInternalFieldApi | null): void { batch(() => { - this._setFormValidationSourceError(this._onSubmitSource, [], '') - - if (!field || !this._onSubmitSource.errorTargets?.has(field)) return - - this._clearFieldValidationSourceError(field, this._onSubmitSource) - this._atoms.meta.errorFields.set((prev) => - reconcileFormErrorFields(prev, [field]), - ) - field._pruneIfUnused() + if (field) + visitFieldAndAncestors(field, (target) => { + target._errorStore.clearStaleErrors(canClear) + }) + this._fieldRootNode._errorStore.clearStaleErrors(canClear) }) } @@ -813,7 +682,7 @@ export class InternalFormApi implements FormApi< fieldErrors: Iterable]>, routingRoot: AnyInternalFieldApi | InternalRootFieldApi = this ._fieldRootNode, - ): Map> { + ): Map> { const resolvedFieldErrors = new Map< AnyInternalFieldApi, Array @@ -855,273 +724,41 @@ export class InternalFormApi implements FormApi< return resolvedFieldErrors } - _setFormValidationSourceError( - validationSource: AnyInternalValidationSourceInstance, - errors: Array, - sourceEvent: string, - ) { - this._atoms.meta.formErrors.set((prev) => { - const nextErrors = setValidationSourceError( - prev.validationSourceErrors, - validationSource, - errors, - sourceEvent, - ) - - if (!nextErrors) return prev - - return { - ...prev, - validationSourceErrors: nextErrors.errorMap, - } - }) - } - - _setFieldValidationSourceError( - field: AnyInternalFieldApi, - validationSource: AnyInternalValidationSourceInstance, - errors: Array, - sourceEvent: string, - ) { - field._setMeta((prev) => { - const nextErrors = setValidationSourceError( - prev._validationSourceErrors, - validationSource, - errors, - sourceEvent, - ) - - if (!nextErrors) return prev - - return { - ...prev, - _validationSourceErrors: nextErrors.errorMap, - } satisfies InternalBaseFieldMeta - }) - } - - _clearFieldValidationSourceError( - field: AnyInternalFieldApi, - validationSource: AnyInternalValidationSourceInstance, - ) { - 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, - validationSources: ReadonlyArray, - sourceEvent: string, - ) { - field._setMeta((prev) => { - const clearedErrors = clearValidationSourceErrorsFromEvent( - prev._validationSourceErrors, - validationSources, - sourceEvent, - ) - - if (!clearedErrors) return prev - - return { - ...prev, - _validationSourceErrors: clearedErrors.errorMap, - } - }) - field._pruneIfUnused() - } - - _processValidationResult( - result: PipelineResult>, - sourceEvent: string, - ) { - const validatorInstance = - result.validatorInstance as InternalFormValidatorInstance - - const parsedResult = parseValidationResult(result.result) - const resolvedFieldErrors = this._resolveRoutedFieldErrors( - Object.entries(parsedResult.subfields ?? {}), - ) - - batch(() => { - this._setFormValidationSourceError( - validatorInstance, - parsedResult.self ?? [], - sourceEvent, - ) - - const oldFieldRefs = validatorInstance.errorTargets ?? undefined - const { fieldRefs, affectedFields, didFieldRefsChange } = - reconcileRoutedFieldErrors( - validatorInstance, - resolvedFieldErrors, - oldFieldRefs, - (field, instance, errors) => - this._setFieldValidationSourceError( - field, - instance as InternalFormValidatorInstance, - errors, - sourceEvent, - ), - (field, instance) => - this._clearFieldValidationSourceError( - field, - instance as InternalFormValidatorInstance, - ), - ) - - if (didFieldRefsChange) - validatorInstance.errorTargets = fieldRefs.size > 0 ? fieldRefs : null - - if (affectedFields.size > 0) { - this._atoms.meta.errorFields.set((prev) => - reconcileFormErrorFields(prev, affectedFields), - ) - } - }) + _pruneErrorTargets(targets: Iterable): void { + for (const target of targets) { + if (target instanceof InternalFieldApi) target._pruneIfUnused() + } } - _processSubmitValidationResult( + _applyValidationResult( + sourceOrIndex: ValidationErrorSource | number, result: FormValidationResult, - sourceEvent: string, + sourceEvent: ValidatorEvents, ): 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) { - this._onSubmitSource.errorTargets = - fieldRefs.size > 0 ? fieldRefs : null - } + let source: ValidationErrorSource | undefined + if (typeof sourceOrIndex === 'number') { + source = this._validatorInstances?.[sourceOrIndex] + } else { + source = sourceOrIndex + } - if (affectedFields.size > 0) { - this._atoms.meta.errorFields.set((prev) => - reconcileFormErrorFields(prev, affectedFields), - ) - } - }) - } + if (!source) return - _runMountValidation(): void { - const pipeline = this._validatorInstances?.filter( - (validatorInstance) => !validatorInstance.didRunOnMount, + const parsed = parseValidationResult(result) + const contributions = new Map( + this._resolveRoutedFieldErrors(Object.entries(parsed.subfields ?? {})), ) - if (!pipeline || pipeline.length === 0) return - - pipeline.forEach((validatorInstance) => - validatorInstance.markMountValidationRan(), + contributions.set(this._fieldRootNode, parsed.self ?? []) + this._pruneErrorTargets( + replaceSourceErrors(source, sourceEvent, contributions), ) - - this._setValidationCount((count) => count + 1) - - const { didRun, asyncPromise } = runFormMountValidatorPipeline({ - // TODO investigate this error. Leftover? - pipeline: pipeline as any, - formApi: this, - onResult: (result) => this._processValidationResult(result, 'mount'), - }) - - if (!didRun) { - this._setValidationCount((count) => Math.max(0, count - 1)) - return - } - - if (asyncPromise) { - void asyncPromise.finally(() => { - this._setValidationCount((count) => Math.max(0, count - 1)) - }) - return - } - - this._setValidationCount((count) => Math.max(0, count - 1)) } - async _runFormValidation( - signal: ClientSideEvents, - opts?: FieldApiOverrideOptions & { - onResult?: boolean - hasFailedBefore?: boolean - }, - ): Promise { - const pipeline = this._validatorInstances - if (!pipeline) - return { - results: [], - hasErrors: false, - thrownError: null, - } - if (pipeline.length === 0) - return { - results: [], - hasErrors: false, - thrownError: null, - } - - this._setValidationCount((count) => count + 1) - try { - return await runFormValidatorPipeline({ - context: { - scope: 'form', - event: signal, - // TypeScript doesn't instantly complain, but instead decides to wait a while. - // Just leave it as never. - formApi: this as never, - triggerFieldApi: opts?.fieldApiOverride ?? undefined, - }, - hasFailedBefore: opts?.hasFailedBefore ?? false, - pipeline: pipeline as never, - onResult: - opts?.onResult !== false - ? (result) => this._processValidationResult(result, signal) - : undefined, - }) - } finally { - this._setValidationCount((count) => Math.max(0, count - 1)) - } + _runFormValidation( + event: ClientSideEvents, + opts: FormValidationOptions, + ): ClientValidationRun | undefined { + return startFormValidation(this, this._validationRuns, event, opts) } _setValidationCount(updater: Updater): void { @@ -1135,157 +772,15 @@ export class InternalFormApi implements FormApi< hasFailedBefore?: boolean }, ) => { - const pipelineResults = await this._runFormValidation(signal, opts) - return pipelineResults.results - .map(({ result }) => result) - .filter(isErrorResult) + const run = this._runFormValidation(signal, { ...opts, catchErrors: false }) + await run?.pending + return ( + run?.collector + .getResults() + .flatMap(({ value }) => getValidationErrors(value.result)) ?? + [] + ) } - _handleSubmitPromise: Promise | null = null - - handleSubmit = (): Promise>> => { - if (this._handleSubmitPromise) return this._handleSubmitPromise - - this._notifyFormListener('submit', null) - - const handleSubmitPromise = runSubmissionProcess(this).finally(() => { - if (this._handleSubmitPromise === handleSubmitPromise) { - this._handleSubmitPromise = null - } - }) - - this._handleSubmitPromise = handleSubmitPromise - - return handleSubmitPromise - } + handleSubmit = () => handleFormSubmit(this) } - -/** - * Error cleanup - * - * DONT HAVE YET - mount - * -> in v1, it refers to Component mount - * -> in v2, we can check if `mount.run` is a promise. If not, then we can use that as immediate error feedback - * otherwise, delay processing with .then() -> "init" rather than mount - * - * change - * blur - * submit - * - * -> when are these cleared? - * - * Submission and onMount errors clear after change/blur - * -> Form-level errors should clear when any field changes / blurs - * -> { form: x, fields: {} } -> split into form-level and field-level - * -> Field-level errors should ONLY clear if that specific field changes / blurs - * - * - * Linked Fields - * -> a field has a Set that need to be notified - * -> if a field B has a field-level error from onSubmit, and it listens to field A, - * then changing field A should trigger field B validation, but it should NOT clear field B errors. - * - * - * Field mounts - * -> is there `listeners.listenToblabla` - * -> if so, for each, `form.getOrCreateFieldApi(name) - * -> const unsubscribe = otherField.attachListener(this) - * - * -> field._update() brings in different names - * -> Map -> unsubscribe - * - * -> repeat process of subscribing - * - * prune condition needs to be extended: listeners need to be size 0 - * - * -> { - * run: () => {}, - * triggers: ['change', 'blur', 'submit', 'mount', 'unmount'], // not the same as submit - * // skipOnSubmit doesn't exist - * listenToFields: ['otherField'] - * } - * - * - * const form = useForm({ defaultValues, validators: [ - * { run: () => 'Form-level' } // This only runs on submit. If field changes/blurs and this error is still there, clear it - * { run: () => ({ form: 'Form-level', fields: { 'name', 'Too short' }})} // If field changes/blurs, remove form-level. If `name` changes, then remove name. - * ]}) - * - */ - -/** - * TODO do testing with basic react example, see what feels good DX wise: - * - * Field A level error, field B level error, field A listens to field B for validation - * -> should Field A clear onSubmit/onMount errors when field B triggered validation? - */ - -/* - // TODO: Talk about user-land listeners moving after a shift or other array operation - - // CURRENT - - - ; - } - - class FormApi { - onTrieMoveOperation() { - // onListenToChanges will auto-run if the user is intentionally listening to the moved value - // because of the reactivity mechanism of the framework - // so we don't need to traverse the whole tree, only the moved operations - } - - onListenToChanges(listenTrieNode, prevName, newName) { - const prevTrieNode = this.getTrie(prevName); - cost newTrieNode = this.getTrie(newName); - // Node was moved but is referentially the same, no changes in internal listeners needed - if (prevTrieNode === newTrieNode) return; - listenTrieNode.listenNodes.remove(prevTrie); - listenTrieNode.listenNodes.add(newTrieNode); - } - - triggerNodeListeners(changedTrieNode) { - // Traverse upwards from current trie node to inform - changedTrieNode.traverseUp((currNode) => { - if (currNode.contains(changedTrieNode)) { - return; // Prevent accidental dependencies on itself/parents - } - currNode.listenNodes.triggerChange(); - }) - } - } - - // TrieMoveOperation: - // - Nodes that are being changed - // -> Child nodes might have stale - - // 'summary' -> 'users[1]' node - // REFERENTIALLY - - // TrieMoveOperation - // moved 'users[1]' -> 'users[0]' - - - - // on node change: - // 1. Am I in the list? - // 2. If so, send updates to each node -*/ - -// node -// / \ -// nodeA nodeB -// / \ -// nodeC nodeD diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index 24244ac3c..ea72ac5df 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -1,12 +1,12 @@ -import type { ReadonlyAtom } from '@tanstack/store' -import type { FormApiArrayMethods } from './FormApiArrayMethods.types.public' -import type { FormApiFieldMethods } from './FormApiFieldMethods.types.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { - FormValidationFailure, - ScopedErrorTypes, + FormattedValidationError, ValidationIssue, -} from '../validation/state.public' -import type { DynamicValidatorTriggers } from '../events.public' +} from '../validation/results/results.public' +import type { FormApiFieldMethods } from './FormApiFieldMethods.types.public' +import type { FormApiArrayMethods } from './FormApiArrayMethods.types.public' +import type { ReadonlyAtom } from '@tanstack/store' +import type { DynamicValidatorTriggers } from '../pipeline/events.public' type FormError = TFormError extends ScopedErrorTypes @@ -193,17 +193,14 @@ export interface FormResetOptions { * `createValidationError` or `createErrorMap` are stored as error state. * `onSubmitInvalid` is awaited after a failed attempt. * - * Calls made while an attempt is in progress return the same promise instead - * of starting another attempt. - * - * @returns A promise resolving to the error results produced by field and - * form validation, plus any validation error returned by `onSubmit` through - * `createValidationError` or `createErrorMap`. The array is empty if none are - * produced. + * @returns A promise resolving to a flat array of issues and error maps from + * field and form validation, plus errors returned by `onSubmit` through its + * validation helpers. Strings become issue objects. The array is empty if no + * errors are produced. * @typeParam TFormData - Library-managed. Do not specify explicitly. */ export type HandleSubmitFn = () => Promise< - Array> + Array> > /** @@ -272,12 +269,13 @@ export interface FormApi< * Field-level validators are not run by this method. * * @param signal - The trigger used to select validators. - * @returns A promise resolving to each error result from the validators that - * ran. Unlike `state.errors`, these results are not flattened. + * @returns A promise resolving to a flat array of issues and error maps from + * the validators that ran. Strings become issue objects; error maps retain + * their field paths. */ validate: ( signal: DynamicValidatorTriggers, - ) => Promise>> + ) => Promise>> /** * Runs submission validation and submits current values when validation @@ -289,13 +287,10 @@ export interface FormApi< * `createValidationError` or `createErrorMap` are stored as error state. * `onSubmitInvalid` is awaited after a failed attempt. * - * Calls made while an attempt is in progress return the same promise instead - * of starting another attempt. - * - * The returned promise resolves to the error results produced by field and - * form validation, plus any validation error returned by `onSubmit` through - * `createValidationError` or `createErrorMap`. The array is empty if none are - * produced. + * The returned promise resolves to a flat array of issues and error maps + * from field and form validation, plus errors returned by `onSubmit` through + * its validation helpers. Strings become issue objects. The array is empty + * if no errors are produced. */ handleSubmit: HandleSubmitFn /** diff --git a/packages/form-core/src/FormApi/array-methods.lib.ts b/packages/form-core/src/FormApi/array-methods.lib.ts index 31f1459c7..2483e3aad 100644 --- a/packages/form-core/src/FormApi/array-methods.lib.ts +++ b/packages/form-core/src/FormApi/array-methods.lib.ts @@ -1,9 +1,12 @@ import { batch } from '@tanstack/store' +import { + collectFieldSubtree, + tryGetFieldApi, + visitFieldSubtree, +} from '../FieldApi/fieldTraversal.lib' import { getTargetField, resolveFieldUpdateOptions } from '../utils.lib' -import { tryGetFieldApi } from '../FieldApi/FieldApi.lib' -import { collectFieldSubtree } from '../FieldApi/fieldTraversal.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { AnyInternalFormApi } from './FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { InternalFieldUpdateOptions } from '../types.lib' function isInvalidArrayMethod( @@ -37,6 +40,23 @@ function isInvalidArrayMethod( return false } +function moveArrayField( + field: AnyInternalFieldApi | null | undefined, + index: number, +): void { + // Preserve surviving listener identities and current notification routes here. + if (!field || field._segment === index) return + + visitFieldSubtree(field, (f) => f._cancelValidation()) + field._moveTo(index) +} + +function shiftArrayField(field: AnyInternalFieldApi, offset: number): void { + if (typeof field._segment === 'string') return + + moveArrayField(field, field._segment + offset) +} + function pushFieldValue({ form, arrayFieldName, @@ -87,26 +107,25 @@ function insertFieldValue({ const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField - batch(() => { - form.setFieldValue( - arrayFieldName, - (prev: Array) => { - const array = prev.slice() - array.splice(index, 0, value) - return array - }, - updateOptions, - ) - - if (!arrayField) return - - // Shift existing children to the right of the insertion index - for (const child of arrayField._children) { - if (typeof child._segment === 'string') continue - if (child._segment >= index) { - child._moveTo(child._segment + 1) + form._setFieldValue({ + fieldName: arrayFieldName, + updater: (prev: Array) => { + const array = prev.slice() + array.splice(index, 0, value) + return array + }, + options: updateOptions, + updateFieldStructure: () => { + if (!arrayField) return + + // Shift existing children to the right of the insertion index + for (const child of arrayField._children) { + if (typeof child._segment === 'string') continue + if (child._segment >= index) { + shiftArrayField(child, 1) + } } - } + }, }) } @@ -157,8 +176,11 @@ function replaceFieldValue({ const seenValidatorFields = new WeakSet() for (const field of collectFieldSubtree(replacedField)) { - field._runFieldValidation('change') - field._notifyValidator('change', seenValidatorFields) + field._runFieldValidation('change', { catchErrors: true }) + field._notifyValidator({ + trigger: 'change', + seenFields: seenValidatorFields, + }) } } @@ -186,28 +208,27 @@ function removeFieldValue({ const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField - batch(() => { - form.setFieldValue( - arrayFieldName, - (prev: Array) => { - const array = prev.slice() - array.splice(index, 1) - return array - }, - updateOptions, - ) - - if (!arrayField) return + form._setFieldValue({ + fieldName: arrayFieldName, + updater: (prev: Array) => { + const array = prev.slice() + array.splice(index, 1) + return array + }, + options: updateOptions, + updateFieldStructure: () => { + if (!arrayField) return - const childToRemove = tryGetFieldApi(arrayField, [index]) - childToRemove?._kill() + const childToRemove = tryGetFieldApi(arrayField, [index]) + childToRemove?._kill() - for (const child of arrayField._children) { - if (typeof child._segment === 'string') continue - if (child._segment > index) { - child._moveTo(child._segment - 1) + for (const child of arrayField._children) { + if (typeof child._segment === 'string') continue + if (child._segment > index) { + shiftArrayField(child, -1) + } } - } + }, }) } @@ -241,32 +262,26 @@ function swapFieldValues({ const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField - batch(() => { - form.setFieldValue( - arrayFieldName, - (prev: Array) => { - const a = prev[indexA] - const b = prev[indexB] - const array = prev.slice() - array[indexA] = b - array[indexB] = a - return array - }, - updateOptions, - ) - - if (!arrayField) return - - const fieldA = tryGetFieldApi(arrayField, [indexA]) + form._setFieldValue({ + fieldName: arrayFieldName, + updater: (prev: Array) => { + const a = prev[indexA] + const b = prev[indexB] + const array = prev.slice() + array[indexA] = b + array[indexB] = a + return array + }, + options: updateOptions, + updateFieldStructure: () => { + if (!arrayField) return - const fieldB = tryGetFieldApi(arrayField, [indexB]) + const fieldA = tryGetFieldApi(arrayField, [indexA]) + const fieldB = tryGetFieldApi(arrayField, [indexB]) - // Fields aren't necessarily mounted, so we should assume - // that the indeces will represent actual values in the array. - // If not, then the user will most likely not iterate over them - // during rendering, so we don't need to worry about them. - fieldA?._moveTo(indexB) - fieldB?._moveTo(indexA) + moveArrayField(fieldA, indexB) + moveArrayField(fieldB, indexA) + }, }) } @@ -300,36 +315,35 @@ function moveFieldValue({ const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField - batch(() => { - form.setFieldValue( - arrayFieldName, - (prev: Array) => { - const array = prev.slice() - const [value] = array.splice(fromIndex, 1) - array.splice(toIndex, 0, value) - return array - }, - updateOptions, - ) - - if (!arrayField) return - - const movingChild = tryGetFieldApi(arrayField, [fromIndex]) - - for (const child of arrayField._children) { - if (typeof child._segment === 'string') continue - if (child === movingChild) continue - - if (fromIndex < toIndex) { - if (child._segment > fromIndex && child._segment <= toIndex) { - child._moveTo(child._segment - 1) + form._setFieldValue({ + fieldName: arrayFieldName, + updater: (prev: Array) => { + const array = prev.slice() + const [value] = array.splice(fromIndex, 1) + array.splice(toIndex, 0, value) + return array + }, + options: updateOptions, + updateFieldStructure: () => { + if (!arrayField) return + + const movingChild = tryGetFieldApi(arrayField, [fromIndex]) + + for (const child of arrayField._children) { + if (typeof child._segment === 'string') continue + if (child === movingChild) continue + + if (fromIndex < toIndex) { + if (child._segment > fromIndex && child._segment <= toIndex) { + shiftArrayField(child, -1) + } + } else if (child._segment >= toIndex && child._segment < fromIndex) { + shiftArrayField(child, 1) } - } else if (child._segment >= toIndex && child._segment < fromIndex) { - child._moveTo(child._segment + 1) } - } - movingChild?._moveTo(toIndex) + moveArrayField(movingChild, toIndex) + }, }) } @@ -350,16 +364,19 @@ function clearFieldValues({ const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField - batch(() => { - form.setFieldValue(arrayFieldName, [], updateOptions) - - if (!arrayField) return - - // Kill all child fields since the array is now empty - // _kill() will remove each child from the parent's children - for (const child of arrayField._children) { - child._kill() - } + form._setFieldValue({ + fieldName: arrayFieldName, + updater: [], + options: updateOptions, + updateFieldStructure: () => { + if (!arrayField) return + + // Kill all child fields since the array is now empty + // _kill() will remove each child from the parent's children + for (const child of arrayField._children) { + child._kill() + } + }, }) } @@ -392,7 +409,7 @@ function filterFieldValues({ const childAtIndex = arrayNode._getChild(index) if (keep) { - childAtIndex?._moveTo(length) + moveArrayField(childAtIndex, length) length++ } else { childAtIndex?._kill() @@ -403,19 +420,23 @@ function filterFieldValues({ if (oldArray.length === filtered.length) { // Setting filtered array would be a no-op, but either way the user // tried to set a value - form._notifyFieldChange(arrayNode, updateOptions) + form._notifyFieldChange(arrayNode, updateOptions, arrayFieldName) } else { form.setFieldValue(arrayFieldName, filtered, updateOptions) } } +function withBatch(mutate: (args: T) => void): (args: T) => void { + return (args) => batch(() => mutate(args)) +} + export const ArrayMethods = { - pushValue: pushFieldValue, - insertValue: insertFieldValue, - removeValue: removeFieldValue, - swapValues: swapFieldValues, - moveValue: moveFieldValue, - replaceValue: replaceFieldValue, - clearValues: clearFieldValues, - filterValues: filterFieldValues, + pushValue: withBatch(pushFieldValue), + insertValue: withBatch(insertFieldValue), + removeValue: withBatch(removeFieldValue), + swapValues: withBatch(swapFieldValues), + moveValue: withBatch(moveFieldValue), + replaceValue: withBatch(replaceFieldValue), + clearValues: withBatch(clearFieldValues), + filterValues: withBatch(filterFieldValues), } diff --git a/packages/form-core/src/FormApi/errorVisibility.public.ts b/packages/form-core/src/FormApi/errorVisibility.public.ts index e4573f7f5..d50a7161f 100644 --- a/packages/form-core/src/FormApi/errorVisibility.public.ts +++ b/packages/form-core/src/FormApi/errorVisibility.public.ts @@ -1,5 +1,5 @@ +import type { FormApi, FormState } from './FormApi.public' import type { ScopedErrorTypes } from '../validation/state.public' -import type { FormState } from './FormApi.public' export interface ErrorVisibilitySubfieldsMeta { isEveryPristine: boolean @@ -34,3 +34,49 @@ export interface ErrorVisibilityContext { export type ErrorVisibilityFn = ( context: ErrorVisibilityContext, ) => boolean + +/** + * Form or group state available to a reusable error visibility policy. + * + * `values` is `unknown` because the policy can be used with different form shapes. + */ +export type ReusableErrorVisibilityState = Omit< + FormApi['state'], + 'values' +> & { + /** Current form values, without assuming a particular form shape. */ + values: unknown +} + +/** State available to a visibility policy shared across different form shapes. */ +export interface ReusableErrorVisibilityContext { + /** Form state with group-scoped flags and counters for fields in a form group. */ + state: ReusableErrorVisibilityState + /** The field whose errors the policy controls. */ + fieldState: ErrorVisibilityFieldState +} + +/** + * An error visibility policy that accepts forms with different value and error types. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + */ +export type ReusableErrorVisibility = ( + context: ErrorVisibilityContext, +) => boolean + +/** + * Creates an error visibility policy that can be shared across forms with different values. + * + * Use an inline `errorVisibility` callback instead when the policy needs + * strongly typed access to the consuming form's `values`. + * + * @param visibility - Returns `true` to show the field's errors, or `false` to hide them. + * @returns The policy to assign to a form or field's `errorVisibility` option. + */ +export function createErrorVisibility( + visibility: (context: ReusableErrorVisibilityContext) => boolean, +): ReusableErrorVisibility { + return visibility +} diff --git a/packages/form-core/src/FormApi/formOptions.lib.ts b/packages/form-core/src/FormApi/formOptions.lib.ts new file mode 100644 index 000000000..93eddff06 --- /dev/null +++ b/packages/form-core/src/FormApi/formOptions.lib.ts @@ -0,0 +1,135 @@ +import { batch } from '@tanstack/store' +import { resolveDefaultOptions } from '../defaultOptions.lib' +import { devtools } from '../devtoolsBridge.lib' +import { visitAllFormFields } from '../FieldApi/fieldTraversal.lib' +import { evaluate, getBy, setBy } from '../utils.lib' +import { applyServerState } from './formServerState.lib' +import type { AnyInternalFormApi, InternalFormApi } from './FormApi.lib' +import type { FormValidator } from '../pipeline/validation/formValidators.public' +import type { ValidatorTriggers } from '../pipeline/events.public' +import type { StandardSchemaV1 } from '../validation/results/standardSchema.public' +import type { FormOptions } from './formOptions.public' +import type { FormListeners } from '../pipeline/listeners/formListeners.public' +import type { + FormOptionsOnSubmitFn, + FormOptionsOnSubmitInvalidFn, +} from './formSubmit.public' + +type InternalFormValidator = Omit< + FormValidator, + 'run' +> & { + run: StandardSchemaV1 | ((context: any) => any) +} + +// Funky form options are cool and all, but just using `any` killed our type inference internally. +// This is hacky, but it should do. +export type FormOptionsOfType = Omit< + FormOptions, + 'validators' | 'listeners' | 'onSubmit' | 'onSubmitInvalid' +> & { + validators?: ReadonlyArray>> + listeners?: FormListeners, any> + onSubmit?: FormOptionsOnSubmitFn, any, any> + onSubmitInvalid?: FormOptionsOnSubmitInvalidFn, any> +} +export type InternalFormOptions = FormOptionsOfType & { + formId: string +} + +// This scales with the amount of top-level fields, with the setter operation +// scaling with the amount of touched fields. + +// It assumes that by the time async defaultValues come in, not many fields have been touched. +// If they were, they're likely in the same section. +function applyDefaultValuesForUntouchedFields( + currentValues: TFormData, + defaultValues: TFormData, + form: AnyInternalFormApi, +): TFormData { + let nextValues = defaultValues + + for (const field of form._fieldRootNode._children) { + if (field.meta.isTouched) { + nextValues = setBy( + nextValues, + field.name, + getBy(currentValues, field.name), + ) + } + } + + return nextValues +} + +function notifyDevtoolsDefaultValuesUpdate(form: AnyInternalFormApi): void { + const updateField = devtools().updateField + if (!updateField) return + + visitAllFormFields(form._fieldRootNode, (current) => updateField(current)) +} + +/** + * Manages the incoming options to change the form state if needed. + * + * Rough work includes: + * - Async default values + * - Server state changing (from something like useActionState) + * - updating listeners and validators + */ +export function updateFormOptions( + form: InternalFormApi, + options: FormOptionsOfType, +): void { + batch(() => { + const resolvedOptions = resolveDefaultOptions( + options, + form._defaultOptions?.form, + ) + const oldOptions = form._options + const didDefaultValuesChange = !evaluate( + resolvedOptions.defaultValues, + form._lastUpdateDefaultValues, + ) + + form._lastUpdateDefaultValues = resolvedOptions.defaultValues + form._defaultValueCache = null + form._options = { + ...resolvedOptions, + defaultValues: didDefaultValuesChange + ? resolvedOptions.defaultValues + : oldOptions.defaultValues, + formId: resolvedOptions.formId ?? oldOptions.formId, + } + + form._updateInstances(oldOptions) + + if (didDefaultValuesChange) { + batch(() => { + const previousValues = form._atoms.values.get() + form._atoms.defaultValuesVersion.set((version) => version + 1) + + if (form._atoms.meta.touchedFieldCount.get() === 0) { + form._atoms.values.set(resolvedOptions.defaultValues) + } else { + form._atoms.values.set((prev) => + applyDefaultValuesForUntouchedFields( + prev, + resolvedOptions.defaultValues, + form, + ), + ) + } + form._cancelChangedValidation(previousValues) + }) + } + + applyServerState( + form, + form._options.serverState ?? null, + resolvedOptions.defaultValues, + ) + if (didDefaultValuesChange) notifyDevtoolsDefaultValuesUpdate(form) + devtools().updateForm?.(form) + }) +} diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index bc4e775dc..13b922a26 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -1,17 +1,17 @@ -import type { GetFormErrorTypes } from '../validation/state.public' -import type { ErrorVisibilityFn } from './errorVisibility.public' -import type { FormListeners } from './formListeners.public' -import type { ServerFormState } from './formServerState.public' import type { FormOptionsOnSubmitFn, FormOptionsOnSubmitInvalidFn, GetValidatorOutputs, } from './formSubmit.public' -import type { FormOptionsValidators } from '../validation/formValidators.public' +import type { ErrorVisibilityFn } from './errorVisibility.public' +import type { FormOptionsValidators } from '../pipeline/validation/formValidators.public' +import type { FormListeners } from '../pipeline/listeners/formListeners.public' +import type { GetFormErrorTypes } from '../validation/state.public' +import type { ServerFormState } from './formServerState.public' import type { StandardSchemaInput, StandardSchemaV1, -} from '../standardSchema.public' +} from '../validation/results/standardSchema.public' import type { Editable, InferUnion } from '../utils.public' declare const formOptionsTypePayloadSymbol: unique symbol diff --git a/packages/form-core/src/FormApi/formServerState.lib.ts b/packages/form-core/src/FormApi/formServerState.lib.ts new file mode 100644 index 000000000..3f502010e --- /dev/null +++ b/packages/form-core/src/FormApi/formServerState.lib.ts @@ -0,0 +1,80 @@ +import { batch } from '@tanstack/store' +import { visitAllFormFields } from '../FieldApi/fieldTraversal.lib' +import { defaultInternalBaseFieldMeta } from '../FieldApi/fieldState.lib' +import { devtools } from '../devtoolsBridge.lib' +import type { AnyInternalFormApi, InternalFormApi } from './FormApi.lib' +import type { ServerFormState } from './formServerState.public' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' + +function resetFieldMetaForServerState(form: AnyInternalFormApi) { + visitAllFormFields(form._fieldRootNode, (field) => { + field._resetValidation() + field._defaultValueCache = null + + field._listenerInstances?.forEach((instance) => instance.resetRuntime()) + field._validatorInstances?.forEach((instance) => instance.resetRuntime()) + + const metaAtom = field._atoms.meta + if (metaAtom && metaAtom.get() !== defaultInternalBaseFieldMeta) { + metaAtom.set(defaultInternalBaseFieldMeta) + devtools().updateField?.(field) + } + }) +} + +// TODO We should lock down how server state can affect the form and when. Server state could be both +// present on construction as well as updates. However, if the component didn't unmount, then we still +// have perfectly reasonable state available to us, no? But validator results would still be newer on the server? +function resetToServerState( + form: InternalFormApi, + serverState: ServerFormState, + defaultValues: TFormData, +): void { + const values = serverState.values ?? defaultValues + + form._defaultValueCache = null + + batch(() => { + form._resetSubmissions() + form._cancelValidation() + form._listenerInstances?.forEach((instance) => instance.resetRuntime()) + form._validatorInstances?.forEach((instance) => instance.resetRuntime()) + form._onSubmitSource.resetRuntime() + resetFieldMetaForServerState(form) + form._atoms.values.set(values) + form._atoms.meta.isDirty.set(false) + form._atoms.meta.touchedFieldCount.set(0) + form._fieldRootNode._clearErrors() + form._atoms.meta.errorFields.set(new Set()) + form._atoms.meta.fieldValidationCount.set(0) + form._atoms.meta.validationCount.set(0) + form._atoms.meta.isSubmitting.set(false) + form._atoms.meta.isSubmitSuccessful.set(false) + form._atoms.meta.submissionAttempts.set(serverState.submissionAttempts) + + for (const result of serverState.validationResults) { + form._applyValidationResult( + result.validatorIndex, + result.result, + 'server', + ) + } + }) +} + +export function applyServerState( + form: InternalFormApi, + serverState: ServerFormState | null, + defaultValues: TFormData, +): void { + if (serverState === form._lastServerState) return + + form._lastServerState = serverState + + if (!serverState) { + form._clearFormValidationSource('server') + return + } + + resetToServerState(form, serverState, defaultValues) +} diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts index 083824c7e..916d6d9b3 100644 --- a/packages/form-core/src/FormApi/formServerState.public.ts +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -1,5 +1,5 @@ -import type { ServerSideEvents } from '../events.public' -import type { FormValidationResult } from '../validation/state.public' +import type { FormattedValidationResult } from '../validation/results/results.public' +import type { ServerSideEvents } from '../pipeline/events.public' import type { GetValidatorOutputs } from './formSubmit.public' export const initialServerFormState: ServerFormState = { @@ -8,11 +8,16 @@ export const initialServerFormState: ServerFormState = { submissionAttempts: 0, } +/** + * Server validation state with formatted error contents for hydration. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface ServerFormState { values: TFormData | undefined validationResults: Array<{ validatorIndex: number - result: FormValidationResult + result: FormattedValidationResult output: unknown | null hasOutput?: boolean }> diff --git a/packages/form-core/src/FormApi/formState.lib.ts b/packages/form-core/src/FormApi/formState.lib.ts index 6b6b40d95..0d89e90a9 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -1,38 +1,5 @@ -import { batch } from '@tanstack/store' -import { - clearValidationSourceErrorsFromEvent, - getValidationSourceErrors, -} from '../validation' import type { FormState } from './FormApi.public' import type { AnyInternalFormApi, InternalFormApi } from './FormApi.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { Atom } from '@tanstack/store' -import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' -import type { ValidationSourceErrorMap } from '../validation' -import type { ValidationIssue } from '../validation/state.public' - -export interface FormErrorMeta { - validationSourceErrors: ValidationSourceErrorMap | null -} - -type FormValidatorFieldErrorScope = - | { type: 'all' } - | { type: 'field'; field: AnyInternalFieldApi } - | { type: 'none' } - -interface ClearFormValidationSourceErrorsFromEventArgs { - formErrors: Atom - errorFields: Atom> - validationSources: ReadonlyArray - sourceEvent: string - fieldScope: FormValidatorFieldErrorScope - clearFieldEventErrors: ( - field: AnyInternalFieldApi, - validationSources: ReadonlyArray, - sourceEvent: string, - ) => void - reconcileErrorFields?: boolean -} type OverridableFormState = Omit, 'values' | 'errors'> @@ -40,7 +7,6 @@ export type FormStateOverrides = { [TKey in keyof OverridableFormState]?: () => OverridableFormState[TKey] } -const formErrorsCache = new WeakMap>() const formStateDefaultValuesVersionKey = Symbol( 'tanstack-form-default-values-version', ) @@ -76,137 +42,6 @@ const eagerFormStateKeys = [ 'submissionAttempts', ] as const -function getFormErrors( - form: InternalFormApi, -): Array { - const baseFormErrors = form._atoms.meta.formErrors.get() - let formErrors = formErrorsCache.get(baseFormErrors) - if (!formErrors) { - formErrors = getValidationSourceErrors( - baseFormErrors.validationSourceErrors, - ) - formErrorsCache.set(baseFormErrors, formErrors) - } - return formErrors -} - -function hasFormValidationSourceFieldEventError( - field: AnyInternalFieldApi, - validationSource: AnyInternalValidationSourceInstance, - sourceEvent: string, -): boolean { - return ( - field._getBaseMeta()._validationSourceErrors?.get(validationSource) - ?.sourceEvent === sourceEvent - ) -} - -function hasFieldErrors(field: AnyInternalFieldApi): boolean { - const meta = field._getBaseMeta() - - if (meta._validationSourceErrors) return true - if (meta.childContributionCounts.error > 0) return true - - return false -} - -export function reconcileFormErrorFields( - errorFields: Set, - fields: Iterable, -): Set { - const nextErrorFields = new Set(errorFields) - - for (const field of fields) { - if (hasFieldErrors(field)) { - nextErrorFields.add(field) - } else { - nextErrorFields.delete(field) - } - } - - return nextErrorFields -} - -export function clearFormValidationSourceErrorsFromEvent({ - formErrors, - errorFields, - validationSources, - sourceEvent, - fieldScope, - clearFieldEventErrors, - reconcileErrorFields = false, -}: ClearFormValidationSourceErrorsFromEventArgs): void { - const affectedFields = new Set() - - batch(() => { - formErrors.set((prev) => { - const clearedErrors = clearValidationSourceErrorsFromEvent( - prev.validationSourceErrors, - validationSources, - sourceEvent, - ) - if (!clearedErrors) return prev - - return { - ...prev, - validationSourceErrors: clearedErrors.errorMap, - } - }) - - const instancesByField = new Map< - AnyInternalFieldApi, - Array - >() - - if (fieldScope.type === 'all') { - for (const validationSource of validationSources) { - for (const field of validationSource.errorTargets ?? []) { - if ( - !hasFormValidationSourceFieldEventError( - field, - validationSource, - sourceEvent, - ) - ) { - continue - } - - const fieldInstances = instancesByField.get(field) ?? [] - fieldInstances.push(validationSource) - instancesByField.set(field, fieldInstances) - affectedFields.add(field) - } - } - } 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, instances] of instancesByField) { - clearFieldEventErrors(field, instances, sourceEvent) - instances.forEach((validationSource) => - validationSource.deleteErrorTarget(field), - ) - } - - if (reconcileErrorFields && affectedFields.size > 0) { - errorFields.set((prev) => reconcileFormErrorFields(prev, affectedFields)) - } - }) -} - function getFormStateValue< TFormData, TKey extends keyof FormState, @@ -230,19 +65,19 @@ function getFormStateValue< return (overrides.isDefaultValue?.() ?? form._getIsDefaultValue()) as never case 'errors': - return getFormErrors(form) as never + return form._fieldRootNode._errorStore.errors as never case 'isValid': return (overrides.isValid?.() ?? - (getFormErrors(form).length === 0 && + (form._fieldRootNode._errorStore.errors.length === 0 && form._atoms.meta.errorFields.get().size === 0)) as never case 'isInvalid': return (overrides.isInvalid?.() ?? - (getFormErrors(form).length > 0 || + (form._fieldRootNode._errorStore.errors.length > 0 || form._atoms.meta.errorFields.get().size > 0)) as never case 'canSubmit': return (overrides.canSubmit?.() ?? (!form._atoms.meta.isSubmitting.get() && - getFormErrors(form).length === 0 && + form._fieldRootNode._errorStore.errors.length === 0 && form._atoms.meta.errorFields.get().size === 0)) as never case 'isSubmitting': return (overrides.isSubmitting?.() ?? diff --git a/packages/form-core/src/FormApi/formSubmit.public.ts b/packages/form-core/src/FormApi/formSubmit.public.ts index 3a92e5bd3..6da73911c 100644 --- a/packages/form-core/src/FormApi/formSubmit.public.ts +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -1,20 +1,18 @@ -import type { StandardSchemaV1 } from '../standardSchema.public' -import type { PARSED_ISSUES } from '../validation/parseIssues.lib' +import type { PARSED_ISSUES } from '../validation/results/parseIssues.lib' import type { FormValidationFailure, - InferredErrorMapItem, ParseIssuesFn, - ScopedErrorTypes, ValidationErrorMap, ValidationFailure, ValidationFailureItem, - ValidationFieldMap, ValidationOutput, -} from '../validation/state.public' +} from '../validation/results/results.public' +import type { StandardSchemaV1 } from '../validation/results/standardSchema.public' +import type { ParseValidatorCondition } from '../pipeline/validation/formValidators.public' +import type { submitErrorSymbol } from './handleSubmit.lib' import type { FormApi } from './FormApi.public' -import type { ParseValidatorCondition } from '../validation/formValidators.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { Awaitable } from '../types.public' -import type { submitErrorSymbol } from './handleSubmit.lib' type UnwrapValidatorFunction = TOutput extends { readonly [PARSED_ISSUES]: true diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index b9f3c8db7..4524a3d7b 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -1,22 +1,20 @@ import { batch } from '@tanstack/store' import { + getValidationErrors, isErrorResult, - isValidationErrorMap, - parseValidationResult, -} from '../validation' -import { createErrorMap } from '../validation/createErrorMap.lib' -import { parseIssuesFactory } from '../validation/parseIssues.lib' -import type { InternalFormApi } from './FormApi.lib' -import type { ValidationErrorInput } from '../validation.public' +} from '../validation/results/errors.lib' +import { createErrorMap } from '../validation/results/createErrorMap.lib' +import { parseIssuesFactory } from '../validation/results/parseIssues.lib' +import { SubmissionAttempt } from '../submissionAttempt.lib' +import type { SubmissionOutcome } from '../submissionAttempt.lib' +import type { FormSubmitContext, OnSubmitError } from './formSubmit.public' import type { FormValidationFailure, FormValidationResult, -} from '../validation/state.public' -import type { - CreateValidationErrorFn, - FormSubmitContext, - OnSubmitError, -} from './formSubmit.public' + FormattedValidationError, +} from '../validation/results/results.public' +import type { InternalFormApi } from './FormApi.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' export const submitErrorSymbol = Symbol('Submit error') @@ -28,15 +26,7 @@ function isSubmitError( ) } -const createValidationError: CreateValidationErrorFn = < - TError extends FormValidationFailure, ->( - error: TError, -): OnSubmitError => { - return createSubmitError(error) -} - -function createSubmitError>( +function createValidationError>( error: TError, ): OnSubmitError { return { [submitErrorSymbol]: error } @@ -52,163 +42,215 @@ function withSubmitError unknown>( } } -export async function runSubmissionProcess( - form: InternalFormApi, -): Promise>> { - const submitResetVersion = form._atoms.resetVersion.get() - const hasResettedFormDuringSubmit = () => - form._atoms.resetVersion.get() !== submitResetVersion - - batch(() => { - form._atoms.meta.submissionAttempts.set((prev) => prev + 1) - form._atoms.meta.isSubmitting.set(true) - }) +interface FormSubmissionValidation { + hasFailed: boolean + errors: Array> + validatorOutputs: Array +} - const submissionData = { +async function validateFormSubmission( + form: InternalFormApi, + submission: SubmissionAttempt>, +): Promise> { + const validation: FormSubmissionValidation = { hasFailed: false, - submitError: - null satisfies FormValidationResult as FormValidationResult, + errors: [], + validatorOutputs: [], } - const fields = form._fieldRootNode._touchAllFieldsAndCollectSubmitValidators() + if (!submission.isActive()) return validation + form._notifyFormListener('submit', null) + if (!submission.isActive()) return validation + + for (const field of submission.notificationFields) { + if (!submission.isActive()) return validation + field._handleEvent( + { + causeValidation: false, + markAsBlurred: false, + markAsDirty: false, + markAsTouched: true, + notifyParentFields: false, + }, + 'submit', + ) + } - const fieldValidatorResults = await Promise.all( - fields.map((field) => - field._runFieldValidation('submit', { onResult: false }), - ), + const fieldValidatorResults: Array< + ReturnType | undefined + > = [] + const fieldOutcomes = await Promise.allSettled( + Array.from(submission.fields.keys(), async (field, index) => { + if (!submission.isActive()) return null + const run = field._runFieldValidation('submit', { + catchErrors: false, + onResult: false, + submission, + }) + fieldValidatorResults[index] = run + await run?.pending + return run + }), ) - if (hasResettedFormDuringSubmit()) { - return [] - } + if (!submission.isActive()) return validation - const fieldResults: Array = [] + const fieldResults: Array> = [] batch(() => { - for (let i = 0; i < fieldValidatorResults.length; i++) { - const field = fields[i]! - const pipelineResult = fieldValidatorResults[i]! - - if (pipelineResult.thrownError !== null) { - submissionData.hasFailed = true - } - - for (const result of pipelineResult.results) { - if (isErrorResult(result.result)) { - submissionData.hasFailed = true - fieldResults.push(result.result) + let fieldIndex = 0 + for (const field of submission.fields.keys()) { + const pipelineResult = fieldValidatorResults[fieldIndex] + fieldIndex++ + if (!pipelineResult) continue + + for (const result of pipelineResult.collector.getResults()) { + if (!submission.isActive()) return + if (isErrorResult(result.value.result)) { + validation.hasFailed = true + fieldResults.push( + ...getValidationErrors(result.value.result), + ) } field._processValidationResult(result, 'submit') } } }) - // TODO maybe some users don't want form validation to run if field validation failed. - // Configurable option with opt-out wouldn't hurt. + if (!submission.isActive()) return validation + + for (const outcome of fieldOutcomes) { + if (outcome.status === 'rejected') throw outcome.reason + } + + // Maybe some users don't want form validation to run if field validation failed. + // Configurable option with opt-out wouldn't hurt, but wait for demand. // Also keep in mind this would apply to handleChange too. - const formPipelineResult = await form._runFormValidation('submit', { - hasFailedBefore: submissionData.hasFailed, + const formRun = form._runFormValidation('submit', { + catchErrors: false, + hasFailedBefore: validation.hasFailed, + submission, }) + await formRun?.pending - if (hasResettedFormDuringSubmit()) { - return [] - } + if (!submission.isActive() || !formRun) return validation - if (formPipelineResult.thrownError !== null || formPipelineResult.hasErrors) { - submissionData.hasFailed = true + if (formRun.collector.hasErrors()) { + validation.hasFailed = true } - const errorResults = formPipelineResult.results - .map(({ result }) => result) - .filter(isErrorResult) + validation.errors = formRun.collector + .getResults() + .flatMap(({ value }) => getValidationErrors(value.result)) .concat(fieldResults) - const cleanup = () => { - if (hasResettedFormDuringSubmit()) { - return - } + if (!validation.hasFailed) { + validation.validatorOutputs = formRun.collector.getOutputs( + formRun.validatorCount, + ) + } + return validation +} - batch(() => { - form._atoms.meta.isSubmitting.set(false) - form._atoms.meta.isSubmitSuccessful.set(!submissionData.hasFailed) - }) +async function runFormSubmissionCallbacks( + form: InternalFormApi, + submission: SubmissionAttempt>, + validation: FormSubmissionValidation, +): Promise>> { + const submissionData = { + hasFailed: validation.hasFailed, + submitError: + null satisfies FormValidationResult as FormValidationResult, } + const errorResults = validation.errors + const earlyExit = { errors: [], successful: false } const finishInvalidSubmission = async ( value: TFormData, - ): Promise>> => { - try { - await form._options.onSubmitInvalid?.({ - value, - formApi: form as never, - }) - } finally { - cleanup() - } - - if (hasResettedFormDuringSubmit()) { - return [] - } + ): Promise>> => { + if (!submission.isActive()) return earlyExit - // TODO ew. Resolve this differently, this is really bad. - return errorResults.flatMap>((error) => { - const { self, subfields } = parseValidationResult(error) - if (isValidationErrorMap(error)) { - return [{ ...error, form: self ?? [], fields: subfields ?? {} }] - } - return self ?? [] + await form._options.onSubmitInvalid?.({ + value, + formApi: form, }) + + if (!submission.isActive()) return earlyExit + + return { errors: errorResults, successful: false } } if (submissionData.hasFailed) { - return finishInvalidSubmission(form.state.values) + return await finishInvalidSubmission(submission.value) } - const validatorOutputs = form._validatorInstances?.map((v) => v.output) ?? [] - const value = form.state.values + const value = submission.value try { const maybeError = await form._options.onSubmit?.({ formApi: form as never, - validatorOutputs, + validatorOutputs: validation.validatorOutputs, value, createValidationError, createErrorMap: withSubmitError(createErrorMap), parseIssues: withSubmitError(parseIssuesFactory(value, 'field')), } satisfies FormSubmitContext as any) - if (hasResettedFormDuringSubmit()) { - return [] - } + if (!submission.isActive()) return earlyExit // Store onSubmit errors separately from installed validator instances. if (isSubmitError(maybeError)) { const error = maybeError[submitErrorSymbol] - form._processSubmitValidationResult(error, 'submit') + if (submission.canApplyValidationResults()) + form._applyValidationResult(form._onSubmitSource, error, 'submit') submissionData.submitError = error } else { - form._processSubmitValidationResult(null, 'submit') + if (submission.canApplyValidationResults()) + form._applyValidationResult(form._onSubmitSource, null, 'submit') } } catch (e) { - if (hasResettedFormDuringSubmit()) { - return [] - } + if (!submission.isActive()) return earlyExit console.error(e) submissionData.hasFailed = true } + if (!submission.isActive()) return earlyExit + batch(() => { if (isErrorResult(submissionData.submitError)) { submissionData.hasFailed = true - errorResults.push(submissionData.submitError) + errorResults.push(...getValidationErrors(submissionData.submitError)) } }) if (submissionData.hasFailed) { - return finishInvalidSubmission(value) + return await finishInvalidSubmission(value) } - cleanup() - return errorResults + return { errors: errorResults, successful: true } +} + +export function handleFormSubmit(form: InternalFormApi) { + const values = form._atoms.values.get() + const submission = new SubmissionAttempt>( + { + values, + value: values, + node: form._fieldRootNode, + validators: form._validatorInstances, + owner: { + submissions: form._submissions, + submissionAttempts: form._atoms.meta.submissionAttempts, + isSubmitting: form._atoms.meta.isSubmitting, + isSubmitSuccessful: form._atoms.meta.isSubmitSuccessful, + }, + }, + ) + submission.start({ + validate: () => validateFormSubmission(form, submission), + callbacks: (validation) => + runFormSubmissionCallbacks(form, submission, validation), + }) + return submission.promise } diff --git a/packages/form-core/src/FormApi/initialFormState.lib.ts b/packages/form-core/src/FormApi/initialFormState.lib.ts new file mode 100644 index 000000000..7eae547b5 --- /dev/null +++ b/packages/form-core/src/FormApi/initialFormState.lib.ts @@ -0,0 +1,88 @@ +import type { ServerFormState } from './formServerState.public' +import type { FormValidationResult } from '../validation/results/results.public' + +// Constructor seeds combine formatted server results with raw mount results. +export interface InitialFormValidationResult { + validatorIndex: number + result: FormValidationResult + sourceEvent: 'server' | 'mount' +} + +export interface InitialFormMeta { + isDirty: boolean + touchedFieldCount: number + fieldValidationCount: number + validationCount: number + isSubmitting: boolean + isSubmitSuccessful: boolean + submissionAttempts: number +} + +export interface InitialFormStateOptions { + /** The initial value snapshot already supplied to mount validation. */ + values: TFormData + defaultValues: NoInfer + serverState?: ServerFormState> | null + /** Only the mount run's initial syncResults batch belongs here. */ + mountResults?: ReadonlyArray<{ + instance: { index: number } + value: { result: FormValidationResult } + }> + hasPendingMount?: boolean +} + +/** Selects the value snapshot before mount validation starts. */ +export function getInitialFormValues( + defaultValues: TFormData, + serverState?: ServerFormState> | null, +): TFormData { + return serverState?.values ?? defaultValues +} + +/** Builds scalar atom seeds and unprocessed results for later runtime binding. */ +export function createInitialFormState({ + values, + defaultValues, + serverState, + mountResults = [], + hasPendingMount = false, +}: InitialFormStateOptions) { + const results = new Map>() + + for (const result of serverState?.validationResults ?? []) { + results.set(result.validatorIndex, { + validatorIndex: result.validatorIndex, + result: result.result, + sourceEvent: 'server', + }) + } + + for (const { instance, value } of mountResults) { + results.set(instance.index, { + validatorIndex: instance.index, + result: value.result, + sourceEvent: 'mount', + }) + } + + const meta: InitialFormMeta = { + isDirty: false, + touchedFieldCount: 0, + fieldValidationCount: 0, + validationCount: hasPendingMount ? 1 : 0, + isSubmitting: false, + isSubmitSuccessful: false, + submissionAttempts: serverState?.submissionAttempts ?? 0, + } + + return { + values, + defaultValues, + meta, + resetVersion: 0, + defaultValuesVersion: 0, + validationResults: Array.from(results.values()).sort( + (left, right) => left.validatorIndex - right.validatorIndex, + ), + } +} diff --git a/packages/form-core/src/FormApi/initializeForm.lib.ts b/packages/form-core/src/FormApi/initializeForm.lib.ts new file mode 100644 index 000000000..94f46c821 --- /dev/null +++ b/packages/form-core/src/FormApi/initializeForm.lib.ts @@ -0,0 +1,64 @@ +import { createAtom } from '@tanstack/store' +import { InternalRootFieldApi } from '../FieldApi/RootFieldApi.lib' +import { startFormMountValidation } from '../pipeline/validation/ownerValidation.lib' +import { OnSubmitErrorSource } from '../validation/errorStorage.lib' +import { + compareFormStateSnapshots, + getFormStateSnapshot, +} from './formState.lib' +import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' +import type { FormAtoms, FormMetaAtoms, InternalFormApi } from './FormApi.lib' +import type { InitialFormMeta } from './initialFormState.lib' +import type { ValidationRunTracker } from '../pipeline/runTracker.lib' + +function createInitialFormMetaAtoms(meta: InitialFormMeta): FormMetaAtoms { + return { + isDirty: createAtom(meta.isDirty), + touchedFieldCount: createAtom(meta.touchedFieldCount), + errorFields: createAtom(new Set()), + fieldValidationCount: createAtom(meta.fieldValidationCount), + validationCount: createAtom(meta.validationCount), + isSubmitting: createAtom(meta.isSubmitting), + isSubmitSuccessful: createAtom(meta.isSubmitSuccessful), + submissionAttempts: createAtom(meta.submissionAttempts), + } +} + +export function initializeForm( + form: InternalFormApi, + tracker: ValidationRunTracker, +) { + const mountValidation = startFormMountValidation({ + defaultValues: form.defaultValues, + serverState: form._lastServerState, + validators: form._validatorInstances, + tracker, + }) + + try { + const initialState = mountValidation.initialState + const atoms: FormAtoms = { + values: createAtom(initialState.values), + meta: createInitialFormMetaAtoms(initialState.meta), + resetVersion: createAtom(initialState.resetVersion), + defaultValuesVersion: createAtom(initialState.defaultValuesVersion), + } + const fieldRootNode = new InternalRootFieldApi(form) + const onSubmitSource = new OnSubmitErrorSource() + // The constructor assigns these objects before the state atom is read. + const atom = createAtom(() => getFormStateSnapshot(form), { + compare: compareFormStateSnapshots, + }) + + return { + atoms, + atom, + fieldRootNode, + onSubmitSource, + complete: () => mountValidation.complete(form), + } + } catch (error) { + mountValidation.cancel() + throw error + } +} diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 1cf4ef4e1..b9666b445 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -1,65 +1,58 @@ -import { batch, createAtom, shallow } from '@tanstack/store' -import { createOutput } from '../validationOutput.lib' +import { batch, createAtom } from '@tanstack/store' +import { ValidatorInstance } from '../pipeline/validation/ValidatorInstance.lib' +import { ValidationRunTracker } from '../pipeline/runTracker.lib' import { concatenateFieldNames, getBy, setBy } from '../utils.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' +import { startGroupValidation } from '../pipeline/validation/ownerValidation.lib' +import { transformFieldOptionsFieldNames } from '../FieldApi/fieldOptions.lib' +import { + removeSourceErrors, + replaceSourceErrors, +} from '../validation/errorStorage.lib' import { - clearValidationSourceErrorsFromEvent, - getValidationSourceErrors, - isErrorResult, - isValidationTriggerEnabled, + getValidationErrors, parseValidationResult, - reconcileRoutedFieldErrors, - runGroupMountValidatorPipeline, - runValidatorPipeline, - setValidationSourceError, -} from '../validation' -import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' -import { visitFieldSubtree } from '../FieldApi/fieldTraversal.lib' +} from '../validation/results/errors.lib' import { - deriveFromBaseFieldMeta, - hasFieldMetaErrors, -} from '../FieldApi/fieldState.lib' -import { createErrorMap } from '../validation/createErrorMap.lib' -import { parseIssuesFactory } from '../validation/parseIssues.lib' -import { reconcileValidatorInstances } from '../ValidatorInstance.lib' -import { resolveDefaultOptions } from '../defaultOptions.lib' -import type { FormApi } from '../FormApi/FormApi.public' -import type { InternalFormApi } from '../FormApi/FormApi.lib' + collectFieldSubtree, + visitFieldSubtree, +} from '../FieldApi/fieldTraversal.lib' +import { + createFormGroupStateAtom, + getFormGroupStateOverrides, +} from './formGroupState.lib' +import { handleFormGroupSubmit } from './handleSubmit.lib' +import type { FormGroupValidationOutcome } from './handleSubmit.lib' +import type { FormattedValidationError } from '../validation/results/results.public' +import type { ClientValidationRun } from '../pipeline/validation/clientValidation.lib' +import type { GroupValidationOptions } from '../pipeline/validation/ownerValidation.lib' import type { AnyFieldApiOptions, AnyInternalFieldApi, + InternalFieldValidatorInstance, } from '../FieldApi/FieldApi.lib' -import type { - DerivedMetaMarkers, - InternalBaseFieldMeta, - InternalFieldMeta, -} from '../FieldApi/fieldState.lib' -import type { FormStateOverrides } from '../FormApi/formState.lib' import type { DeepKeys, DeepValue } from '../deep-keys.public' -import type { - FormGroupApi, - FormGroupOptions, - FormGroupState, -} from './FormGroupApi.public' import type { ClientValidationTrigger, - FormGroupValidateResult, FormGroupValidators, - ToFormGroupErrorTypes, -} from '../validation.public' -import type { ReadonlyAtom } from '@tanstack/store' -import type { - InternalValidatorInstance, - InternalValidatorInstances, -} from '../ValidatorInstance.lib' +} from '../pipeline/validation/validation.public' import type { ScopedErrorTypes, - ValidationIssue, + ToFormGroupErrorTypes, } from '../validation/state.public' - -interface FormGroupValidationOutcome { - errors: Array> - hasException: boolean -} +import type { + FormGroupApi, + FormGroupOptions, + FormGroupState, +} from './FormGroupApi.public' +import type { FormApi } from '../FormApi/FormApi.public' +import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { ValidatorInstances } from '../pipeline/validation/ValidatorInstance.lib' +import type { ReadonlyAtom } from '@tanstack/store' +import type { FormStateOverrides } from '../FormApi/formState.lib' +import type { PipelineResult } from '../pipeline/state.lib' +import type { ValidatorEvents } from '../pipeline/events.public' +import type { SubmissionAttempt } from '../submissionAttempt.lib' export type AnyInternalFormGroupApi = InternalFormGroupApi< any, @@ -68,11 +61,7 @@ export type AnyInternalFormGroupApi = InternalFormGroupApi< any, any > -export type InternalGroupValidatorInstance = InternalValidatorInstance< - 'group', - AnyInternalFormGroupApi, - AnyInternalFieldApi -> +export type InternalGroupValidatorInstance = ValidatorInstance<'group'> export class InternalFormGroupApi< TFormData, @@ -98,19 +87,25 @@ export class InternalFormGroupApi< TFormError > /** Stable runtime instances correlated with `_options.validators` by slot. */ - _validatorInstances: InternalValidatorInstances< - 'group', - AnyInternalFormGroupApi, - AnyInternalFieldApi - > + _validatorInstances: ValidatorInstances<'group'> = null atom: ReadonlyAtom< FormGroupState> > _isSubmitting = createAtom(false) _isSubmitSuccessful = createAtom(false) _submissionAttempts = createAtom(0) - /** Callbacks that remove validation-count increments made by this group. */ - _validationCountCleanups = new Set<() => void>() + private readonly _validationRuns = new ValidationRunTracker() + private readonly _submissions = new Set< + SubmissionAttempt> + >() + + _resetSubmissions(): void { + const submissions = Array.from(this._submissions) + this._submissions.clear() + for (const submission of submissions) submission.invalidate() + this._isSubmitting.set(false) + this._isSubmitSuccessful.set(false) + } /** Invalidates group state when reset or remount replaces its trie node. */ _groupFieldVersion = createAtom(0) @@ -147,80 +142,9 @@ export class InternalFormGroupApi< 'internal', ) this._groupField._setFormGroup(this) - this._validatorInstances = reconcileValidatorInstances< - 'group', - AnyInternalFormGroupApi, - AnyInternalFieldApi - >({ - definitions: this._options.validators, - instances: null, - owner: this, - scope: 'group', - }) - - const groupMetaMarkers: DerivedMetaMarkers = { - source: undefined, - canDisplayErrors: undefined, - } - - this.atom = createAtom< - FormGroupState> - >( - (prev) => { - void this._groupFieldVersion.get() - - const groupField = this._groupField - const groupBaseMeta = groupField._getBaseMeta() - const groupValue = this.value - const previousGroupMeta = prev?.meta as InternalFieldMeta | undefined - const groupMeta = deriveFromBaseFieldMeta( - groupBaseMeta, - previousGroupMeta, - groupField, - groupValue, - groupMetaMarkers, - ) - const groupErrorMeta = groupBaseMeta._validationSourceErrors - let groupErrors: Array< - ToFormGroupErrorTypes['selfError'] - > - - if ( - prev && - previousGroupMeta?._validationSourceErrors === groupErrorMeta - ) { - groupErrors = prev.errors - } else if (groupErrorMeta) { - groupErrors = getValidationSourceErrors( - groupErrorMeta, - this._validatorInstances, - ) as never - } else { - groupErrors = [] - } + this._updateInstances() - const isInvalid = hasFieldMetaErrors(groupBaseMeta) - const isTouched = groupMeta.isTouched - const isDirty = groupMeta.isDirty - - return { - values: groupValue, - meta: groupMeta, - errors: groupErrors, - isTouched, - isDirty, - isPristine: !isDirty, - isValid: !isInvalid, - isInvalid, - canSubmit: !this._isSubmitting.get() && !isInvalid, - isSubmitting: this._isSubmitting.get(), - isSubmitSuccessful: this._isSubmitSuccessful.get(), - isValidating: groupMeta.isValidating, - submissionAttempts: this._submissionAttempts.get(), - } - }, - { compare: shallow }, - ) + this.atom = createFormGroupStateAtom(this) } /** Attaches this group to the trie node at the given path. */ @@ -228,6 +152,7 @@ export class InternalFormGroupApi< const groupField = this.form._getOrCreateFieldApi({ name }, 'internal') if (groupField !== this._groupField) { + this._groupField._cancelValidation() this._groupField = groupField this._groupFieldVersion.set((version) => version + 1) } @@ -235,30 +160,9 @@ export class InternalFormGroupApi< groupField._setFormGroup(this) } - /** Adds a group-owned validation contribution to a field. */ - _startValidation(field = this._groupField): () => void { - let isComplete = false - - field._setValidationCount((count) => count + 1) - - const finishValidation = () => { - if (isComplete) return - isComplete = true - this._validationCountCleanups.delete(finishValidation) - field._setValidationCount((count) => Math.max(0, count - 1)) - } - - this._validationCountCleanups.add(finishValidation) - - return finishValidation - } - - /** Cancels group validation and clears its field-meta contributions. */ - _cancelValidation(): void { - this._validatorInstances?.forEach((instance) => instance.cancelExecution()) - for (const finishValidation of Array.from(this._validationCountCleanups)) { - finishValidation() - } + /** Cancels this group's runs and the exact field runs it started. */ + _cancelOwnedValidation(): void { + this._validationRuns.cancelAll() } update = ( @@ -270,18 +174,38 @@ export class InternalFormGroupApi< TFormError >, ) => { - const previousValidators = this._options.validators - this._options = resolveDefaultOptions( - options, - this.form._defaultOptions?.formGroup, - ) - this._validatorInstances = reconcileValidatorInstances< - 'group', - AnyInternalFormGroupApi, - AnyInternalFieldApi - >({ + batch(() => { + const previousOptions = this._options + this._options = resolveDefaultOptions( + options, + this.form._defaultOptions?.formGroup, + ) + this._updateInstances(previousOptions) + }) + } + + _updateInstances( + previousOptions?: Pick< + FormGroupOptions< + TFormData, + TGroupName, + TGroupValue, + TGroupValidators, + TFormError + >, + 'validators' + >, + ): void { + if ( + (this._options.validators?.length ?? 0) < + (this._validatorInstances?.length ?? 0) + ) { + this._groupField._cancelValidation() + } + this._validatorInstances = ValidatorInstance.update({ definitions: this._options.validators, - previousDefinitions: previousValidators ?? null, + previousDefinitions: + previousOptions && (previousOptions.validators ?? null), instances: this._validatorInstances, owner: this, scope: 'group', @@ -292,75 +216,11 @@ export class InternalFormGroupApi< mount = (): void => { this._attachToFieldTrie(this.name) - - 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, - groupApi: this, - onResult: (result) => this._processValidationResult(result, 'mount'), - }) - - if (!didRun) { - finishValidation() - return - } - - if (asyncPromise) { - void asyncPromise.finally(finishValidation) - return - } - - finishValidation() + this._runGroupValidation('mount', { catchErrors: true }) } _getScopedFormStateOverrides(): FormStateOverrides { - return { - isTouched: () => { - const meta = this._groupField._getBaseMeta() - return meta.isTouched || meta.childContributionCounts.touched > 0 - }, - isDirty: () => { - const meta = this._groupField._getBaseMeta() - return meta.isDirty || meta.childContributionCounts.dirty > 0 - }, - isPristine: () => { - const meta = this._groupField._getBaseMeta() - return !meta.isDirty && meta.childContributionCounts.dirty === 0 - }, - isDefaultValue: () => { - const value = getBy(this.form._atoms.values.get(), this.name) - return this._groupField._getIsDefaultValue(value) - }, - isValid: () => { - return !hasFieldMetaErrors(this._groupField._getBaseMeta()) - }, - isInvalid: () => { - return hasFieldMetaErrors(this._groupField._getBaseMeta()) - }, - canSubmit: () => { - return ( - !this._isSubmitting.get() && - !hasFieldMetaErrors(this._groupField._getBaseMeta()) - ) - }, - isSubmitting: () => this._isSubmitting.get(), - isSubmitSuccessful: () => this._isSubmitSuccessful.get(), - isValidating: () => { - const meta = this._groupField._getBaseMeta() - return meta.isValidating || meta.childContributionCounts.validating > 0 - }, - submissionAttempts: () => this._submissionAttempts.get(), - } + return getFormGroupStateOverrides(this) } _getPrefixedFieldName(fieldName: string): string { @@ -378,104 +238,27 @@ export class InternalFormGroupApi< ) } - _setFieldValidatorError( - field: AnyInternalFieldApi, - validatorInstance: InternalGroupValidatorInstance, - errors: Array, - sourceEvent: string, - ) { - field._setMeta((prev) => { - const nextErrors = setValidationSourceError( - prev._validationSourceErrors, - validatorInstance, - errors, - sourceEvent, - ) - - if (!nextErrors) return prev - - return { - ...prev, - _validationSourceErrors: nextErrors.errorMap, - } - }) - } - - _clearFieldValidatorError( - field: AnyInternalFieldApi, - validatorInstance: InternalGroupValidatorInstance, - ) { - 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) - } - }) + this.form._pruneErrorTargets(removeSourceErrors(validatorInstance)) } _processValidationResult( - result: { - validatorInstance: InternalGroupValidatorInstance - result: FormGroupValidateResult - output: any | null - hasOutput?: boolean - }, - sourceEvent: string, - ) { - const parsedResult = parseValidationResult(result.result) - const groupField = this._groupField - const oldFieldRefs = result.validatorInstance.errorTargets - ? new Set(result.validatorInstance.errorTargets) - : undefined - oldFieldRefs?.delete(groupField) - const resolvedFieldErrors = this.form._resolveRoutedFieldErrors( - Object.entries(parsedResult.subfields ?? {}), - groupField, + result: PipelineResult, + sourceEvent: ValidatorEvents, + ): void { + const parsed = parseValidationResult(result.value.result) + const resolved = this.form._resolveRoutedFieldErrors( + Object.entries(parsed.subfields ?? {}), + this._groupField, + ) + this.form._pruneErrorTargets( + replaceSourceErrors(result.instance, sourceEvent, [ + [this._groupField, parsed.self ?? []], + ...resolved, + ]), ) - const groupFieldErrors = resolvedFieldErrors.get(groupField) ?? [] - resolvedFieldErrors.delete(groupField) - - batch(() => { - this._setFieldValidatorError( - groupField, - result.validatorInstance, - (parsedResult.self ?? []).concat(groupFieldErrors), - sourceEvent, - ) - - const { fieldRefs } = reconcileRoutedFieldErrors( - result.validatorInstance, - resolvedFieldErrors, - oldFieldRefs, - (field, instance, errors) => - this._setFieldValidatorError( - field, - instance as InternalGroupValidatorInstance, - errors, - sourceEvent, - ), - (field, instance) => - this._clearFieldValidatorError( - field, - instance as InternalGroupValidatorInstance, - ), - ) - - if ((parsedResult.self?.length ?? 0) + groupFieldErrors.length > 0) { - fieldRefs.add(groupField) - } - result.validatorInstance.errorTargets = - fieldRefs.size > 0 ? fieldRefs : null - }) } _visitGroupFields(visitor: (field: AnyInternalFieldApi) => void) { @@ -483,334 +266,186 @@ export class InternalFormGroupApi< } async _runFieldValidations( - signal: ClientValidationTrigger, - ): Promise> { - const fieldValidationPromises: Array< - ReturnType + event: ClientValidationTrigger, + isCancelled: () => boolean, + submission?: SubmissionAttempt, + ): Promise>> { + const pending: Array< + Promise | undefined> > = [] - - this._visitGroupFields((field) => { - fieldValidationPromises.push( - field._runFieldValidation(signal, { - _startValidation: () => this._startValidation(field), - }), + const fields = submission + ? submission.fields.keys() + : collectFieldSubtree(this._groupField) + for (const field of fields) { + if (isCancelled()) break + // An async boundary records synchronous start failures without preventing + // the other fields from starting or escaping sibling settlement. + pending.push( + (async () => { + const run = field._runFieldValidation(event, { + catchErrors: false, + onRegister: this._validationRuns.register, + submission, + }) + await run?.pending + return run + })(), ) - }) - - const results = await Promise.all(fieldValidationPromises) - - return { - errors: results.flatMap((result) => - result.results - .map(({ result: fieldResult }) => fieldResult) - .filter(isErrorResult), - ), - hasException: results.some((result) => result.thrownError !== null), } - } - - _hasFieldEventError( - field: AnyInternalFieldApi, - validatorInstance: InternalGroupValidatorInstance, - sourceEvent: string, - ): boolean { - return ( - field._getBaseMeta()._validationSourceErrors?.get(validatorInstance) - ?.sourceEvent === sourceEvent - ) - } - - _clearFieldEventErrors( - field: AnyInternalFieldApi, - validatorInstances: Array, - sourceEvent: string, - ) { - field._setMeta((prev) => { - const clearedErrors = clearValidationSourceErrorsFromEvent( - prev._validationSourceErrors, - validatorInstances, - sourceEvent, + const outcomes = await Promise.allSettled(pending) + return outcomes.flatMap((outcome) => { + if (outcome.status === 'rejected') throw outcome.reason + return ( + outcome.value?.collector + .getResults() + .flatMap(({ value }) => + getValidationErrors(value.result), + ) ?? [] ) - - if (!clearedErrors) return prev - - return { - ...prev, - _validationSourceErrors: clearedErrors.errorMap, - } }) - field._pruneIfUnused() } - _clearEventErrors( - field: AnyInternalFieldApi, - sourceEvent: string, - event: Exclude, - ) { - const groupField = this._groupField - 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 (!runsOnEvent) { - validatorInstancesToClear.push(validatorInstance) - } - } - - if (validatorInstancesToClear.length === 0) return + _runGroupValidation( + event: ClientValidationTrigger | 'mount', + opts: GroupValidationOptions, + ): ClientValidationRun | undefined { + return startGroupValidation(this, this._validationRuns, event, opts) + } + _clearRoutedErrors(): void { batch(() => { - const groupInstances = validatorInstancesToClear.filter( - (validatorInstance) => - this._hasFieldEventError(groupField, validatorInstance, sourceEvent), - ) - this._clearFieldEventErrors(groupField, groupInstances, sourceEvent) - groupInstances.forEach((validatorInstance) => - validatorInstance.deleteErrorTarget(groupField), - ) - - const instancesToClearFromField: Array = - [] - for (const validatorInstance of validatorInstancesToClear) { - if ( - validatorInstance.errorTargets?.has(field) && - this._hasFieldEventError(field, validatorInstance, sourceEvent) - ) { - validatorInstance.deleteErrorTarget(field) - instancesToClearFromField.push(validatorInstance) - } - } - - if (instancesToClearFromField.length > 0) { - this._clearFieldEventErrors( - field, - instancesToClearFromField, - sourceEvent, - ) - } + for (const source of this._validatorInstances ?? []) + this.form._pruneErrorTargets(removeSourceErrors(source)) }) } - _clearRoutedErrors() { - const validatorInstances = this._validatorInstances ?? [] - if (validatorInstances.length === 0) return - - const fields = new Set() - fields.add(this._groupField) - 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) => { - 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, - _validationSourceErrors: nextErrors.size > 0 ? nextErrors : null, - } - }) - field._pruneIfUnused() - } - } - - _removeRoutedErrorFields(fieldsToRemove: ReadonlySet) { - for (const validatorInstance of this._validatorInstances ?? []) { - for (const field of fieldsToRemove) { - validatorInstance.deleteErrorTarget(field) - } - } - } - _validate = async ( - signal: ClientValidationTrigger, - opts?: { triggerFieldApi?: AnyInternalFieldApi }, + event: ClientValidationTrigger, + opts?: { + triggerFieldApi?: AnyInternalFieldApi + submission?: SubmissionAttempt + }, ): Promise> => { - if (signal !== 'submit' && opts?.triggerFieldApi) { - this._clearEventErrors(opts.triggerFieldApi, 'submit', signal) - if (signal === 'blur') { - this._clearEventErrors(opts.triggerFieldApi, 'mount', signal) - } - } - - const pipeline = this._validatorInstances - if (!pipeline || pipeline.length === 0) { - const fieldOutcome = await this._runFieldValidations(signal) - this._clearRoutedErrors() - return fieldOutcome - } - - const finishValidation = this._startValidation() + const operation = { cancelled: false } + const release = opts?.submission + ? () => {} + : this._validationRuns.register({ + cancel: () => { + operation.cancelled = true + }, + }) try { - const fieldOutcomePromise = this._runFieldValidations(signal) - const results = await runValidatorPipeline< - FormGroupValidateResult - >({ - pipeline, - context: { - scope: 'group', - event: signal, - formApi: this.form, - groupApi: this, - triggerFieldApi: opts?.triggerFieldApi, - }, - hasFailedBefore: false, - getContext: (ctx, instance) => ({ - event: signal, - formApi: this.form, - groupApi: this, - triggerFieldApi: opts?.triggerFieldApi, - signal: ctx.signal, - value: this.value, - createErrorMap, - parseIssues: parseIssuesFactory( - this.value, - instance.fieldErrorTarget, - ), - createOutput, - }), - onResult: (result) => { - this._processValidationResult(result, signal) - }, - }) - - const groupErrors: Array> = - results.results - .map( - ({ result }: { result: FormGroupValidateResult }) => - result, - ) - .filter(isErrorResult) - const fieldOutcome = await fieldOutcomePromise - const errors: Array> = [ - ...groupErrors, - ...fieldOutcome.errors, - ] - + const fieldOutcome = this._runFieldValidations( + event, + () => operation.cancelled || opts?.submission?.isActive() === false, + opts?.submission, + ) + const groupOutcome = (async () => { + if (operation.cancelled || opts?.submission?.isActive() === false) + return null + const run = this._runGroupValidation(event, { + ...opts, + catchErrors: false, + upstreamInvalid: + event === 'submit' + ? // The field outcome propagates its own exception after all siblings + // settle. Block dependent validators without cancelling group work. + fieldOutcome.then( + (errors) => errors.length > 0, + () => true, + ) + : undefined, + }) + await run?.pending + return run + })() + const [fields, group] = await Promise.allSettled([ + fieldOutcome, + groupOutcome, + ]) + if (opts?.submission?.isActive() === false) + return { errors: [], run: null } + if (fields.status === 'rejected') throw fields.reason + if (group.status === 'rejected') throw group.reason return { - errors, - hasException: results.thrownError !== null || fieldOutcome.hasException, + errors: [ + ...(group.value?.collector + .getResults() + .flatMap(({ value }) => + getValidationErrors(value.result), + ) ?? []), + ...fields.value, + ], + run: group.value ?? null, } } finally { - finishValidation() + release() } } validate = async ( signal: ClientValidationTrigger, opts?: { triggerFieldApi?: AnyInternalFieldApi }, - ): Promise>> => { + ): Promise>> => { return (await this._validate(signal, opts)).errors } - handleSubmit = async (): Promise< - Array> - > => { - this._submissionAttempts.set((attempts) => attempts + 1) - this._isSubmitting.set(true) - this._isSubmitSuccessful.set(false) - - try { - const validationOutcome = await this._validate('submit') - const value = this.value - const invalidContext = { - value, - formApi: this.form, - groupApi: this, - } as never - - if ( - validationOutcome.errors.length > 0 || - validationOutcome.hasException || - this.state.isInvalid - ) { - await this._options.onSubmitInvalid?.(invalidContext) - return validationOutcome.errors - } - - const submitContext = { - value, - formApi: this.form, - groupApi: this, - validatorOutputs: Array.from( - this._validatorInstances ?? [], - (validatorInstance) => validatorInstance.output, - ), - } as never - - try { - await this._options.onSubmit?.(submitContext) - } catch (error) { - await this._options.onSubmitInvalid?.(invalidContext) - throw error - } - - this._isSubmitSuccessful.set(true) - return [] - } finally { - this._isSubmitting.set(false) - } + handleSubmit = (): Promise>> => { + return handleFormGroupSubmit(this, this._submissions) } reset = () => { - this._cancelValidation() - this.form._atoms.values.set((prev: TFormData) => - setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), - ) + // TODO(workplan): Cancel older work, restore group values/meta, then dispatch + // reset listeners. Integrate with form/field reset and owner reconstruction; + // group reset notification policy remains deferred. batch(() => { - this._visitGroupFields((field) => { - field._setMeta((prev) => { - if (!prev.isTouched && !prev.isDirty && !prev.isBlurred) return prev - return { - ...prev, - isTouched: false, - isDirty: false, - isBlurred: false, + this._resetSubmissions() + this._groupField._cancelValidation() + const previousValues = new Map() + this._visitGroupFields((field) => previousValues.set(field, field.value)) + this.form._atoms.values.set((prev: TFormData) => + setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), + ) + if (!Object.is(previousValues.get(this._groupField), this.value)) { + this.form._cancelFieldValidation(this.name) + } + batch(() => { + this._visitGroupFields((field) => { + if (!Object.is(previousValues.get(field), field.value)) { + field._cancelValidation() } + field._setMeta((prev) => { + if (!prev.isTouched && !prev.isDirty && !prev.isBlurred) return prev + return { + ...prev, + isTouched: false, + isDirty: false, + isBlurred: false, + } + }) }) + this._isSubmitting.set(false) + this._isSubmitSuccessful.set(false) + this._submissionAttempts.set(0) + this._clearRoutedErrors() }) - this._isSubmitting.set(false) - this._isSubmitSuccessful.set(false) - this._submissionAttempts.set(0) - this._clearRoutedErrors() + this._validatorInstances?.forEach((instance) => instance.resetRuntime()) }) - this._validatorInstances?.forEach((instance) => instance.resetRuntime()) } _cleanup() { - this._cancelValidation() batch(() => { - this._isSubmitting.set(false) - this._isSubmitSuccessful.set(false) - this._clearRoutedErrors() - }) - this._validatorInstances?.forEach((instance) => { - instance.resetRuntime() - instance.resetMountValidation() + this._resetSubmissions() + this._groupField._cancelValidation() + batch(() => { + this._isSubmitting.set(false) + this._isSubmitSuccessful.set(false) + this._clearRoutedErrors() + }) + this._validatorInstances?.forEach((instance) => { + instance.resetRuntime() + }) + this._groupField._setFormGroup(null) }) - this._groupField._setFormGroup(null) } } diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index 9bf686013..8493c66c2 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -1,15 +1,17 @@ -import type { ReadonlyAtom } from '@tanstack/store' +import type { + ScopedErrorTypes, + ToFormGroupErrorTypes, +} from '../validation/state.public' import type { FormApi } from '../FormApi/FormApi.public' -import type { ScopedErrorTypes } from '../validation/state.public' -import type { ClientSideEvents } from '../events.public' import type { - FormGroupValidateResult, FormGroupValidator, FormGroupValidators, - ToFormGroupErrorTypes, ToFormGroupOutputs, -} from '../validation.public' +} from '../pipeline/validation/validation.public' import type { Awaitable } from '../types.public' +import type { ReadonlyAtom } from '@tanstack/store' +import type { ClientSideEvents } from '../pipeline/events.public' +import type { FormattedValidationError } from '../validation/results/results.public' /** * Context passed to a form group's `onSubmit` after group validation succeeds. @@ -141,6 +143,7 @@ export interface FormGroupOptions< > { form: FormApi name: TGroupName + // TODO: Add support for form group listeners. validators?: { // The intersection really helps type instantiations, while // the exclude part helps with additional properties not being swallowed silently. @@ -232,9 +235,11 @@ export interface FormGroupApi< atom: ReadonlyAtom> readonly state: FormGroupState readonly value: TGroupValue + /** Returns a flat array of issues and error maps with group-relative field paths. */ validate: ( signal: ClientSideEvents, - ) => Promise>> - handleSubmit: () => Promise>> + ) => Promise>> + /** Submits the group and returns a flat array of issues and error maps. */ + handleSubmit: () => Promise>> reset: () => void } diff --git a/packages/form-core/src/FormGroupApi/formGroupState.lib.ts b/packages/form-core/src/FormGroupApi/formGroupState.lib.ts new file mode 100644 index 000000000..44c09b874 --- /dev/null +++ b/packages/form-core/src/FormGroupApi/formGroupState.lib.ts @@ -0,0 +1,152 @@ +import { createAtom, shallow } from '@tanstack/store' +import { + deriveFromBaseFieldMeta, + hasFieldMetaErrors, +} from '../FieldApi/fieldState.lib' +import { getBy } from '../utils.lib' +import type { + AnyInternalFormGroupApi, + InternalFormGroupApi, +} from './FormGroupApi.lib' +import type { FormGroupState } from './FormGroupApi.public' +import type { + DerivedMetaMarkers, + InternalFieldMeta, +} from '../FieldApi/fieldState.lib' +import type { FormStateOverrides } from '../FormApi/formState.lib' +import type { DeepKeys, DeepValue } from '../deep-keys.public' +import type { FormGroupValidators } from '../pipeline/validation/validation.public' +import type { ValidationErrorStoreState } from '../validation/errorStorage.lib' +import type { ValidationIssue } from '../validation/results/results.public' +import type { + ScopedErrorTypes, + ToFormGroupErrorTypes, +} from '../validation/state.public' + +export function createFormGroupStateAtom< + TFormData, + TGroupName extends DeepKeys, + TGroupValue extends DeepValue, + TGroupValidators extends FormGroupValidators, + TFormError extends ScopedErrorTypes, +>( + group: InternalFormGroupApi< + TFormData, + TGroupName, + TGroupValue, + TGroupValidators, + TFormError + >, +) { + const groupMetaMarkers: DerivedMetaMarkers = { + source: undefined, + canDisplayErrors: undefined, + } + + let cachedErrorMap: ValidationErrorStoreState | undefined + let cachedGroupErrors: Array = [] + return createAtom< + FormGroupState> + >( + (prev) => { + void group._groupFieldVersion.get() + + const groupField = group._groupField + const groupBaseMeta = groupField._getBaseMeta() + const groupValue = group.value + const previousGroupMeta = prev?.meta as InternalFieldMeta | undefined + const groupMeta = deriveFromBaseFieldMeta( + groupBaseMeta, + previousGroupMeta, + groupField, + groupValue, + groupMetaMarkers, + ) + const errorMap = groupField._errorStore.atom.get() + if (cachedErrorMap !== errorMap) { + cachedErrorMap = errorMap + cachedGroupErrors = + group._validatorInstances?.flatMap( + (source) => errorMap?.get(source) ?? [], + ) ?? [] + } + const groupErrors = cachedGroupErrors as Array< + ToFormGroupErrorTypes['selfError'] + > + const isInvalid = hasFieldMetaErrors( + groupBaseMeta, + groupField._errorStore.hasErrors, + ) + const isTouched = groupMeta.isTouched + const isDirty = groupMeta.isDirty + + return { + values: groupValue, + meta: groupMeta, + errors: groupErrors, + isTouched, + isDirty, + isPristine: !isDirty, + isValid: !isInvalid, + isInvalid, + canSubmit: !group._isSubmitting.get() && !isInvalid, + isSubmitting: group._isSubmitting.get(), + isSubmitSuccessful: group._isSubmitSuccessful.get(), + isValidating: groupMeta.isValidating, + submissionAttempts: group._submissionAttempts.get(), + } + }, + { compare: shallow }, + ) +} + +export function getFormGroupStateOverrides( + group: AnyInternalFormGroupApi, +): FormStateOverrides { + return { + isTouched: () => { + const meta = group._groupField._getBaseMeta() + return meta.isTouched || meta.childMetaCounts.touched > 0 + }, + isDirty: () => { + const meta = group._groupField._getBaseMeta() + return meta.isDirty || meta.childMetaCounts.dirty > 0 + }, + isPristine: () => { + const meta = group._groupField._getBaseMeta() + return !meta.isDirty && meta.childMetaCounts.dirty === 0 + }, + isDefaultValue: () => { + const value = getBy(group.form._atoms.values.get(), group.name) + return group._groupField._getIsDefaultValue(value) + }, + isValid: () => { + return !hasFieldMetaErrors( + group._groupField._getBaseMeta(), + group._groupField._errorStore.hasErrors, + ) + }, + isInvalid: () => { + return hasFieldMetaErrors( + group._groupField._getBaseMeta(), + group._groupField._errorStore.hasErrors, + ) + }, + canSubmit: () => { + return ( + !group._isSubmitting.get() && + !hasFieldMetaErrors( + group._groupField._getBaseMeta(), + group._groupField._errorStore.hasErrors, + ) + ) + }, + isSubmitting: () => group._isSubmitting.get(), + isSubmitSuccessful: () => group._isSubmitSuccessful.get(), + isValidating: () => { + const meta = group._groupField._getBaseMeta() + return meta.isValidating || meta.childMetaCounts.validating > 0 + }, + submissionAttempts: () => group._submissionAttempts.get(), + } +} diff --git a/packages/form-core/src/FormGroupApi/handleSubmit.lib.ts b/packages/form-core/src/FormGroupApi/handleSubmit.lib.ts new file mode 100644 index 000000000..0a1f3d29d --- /dev/null +++ b/packages/form-core/src/FormGroupApi/handleSubmit.lib.ts @@ -0,0 +1,96 @@ +import { SubmissionAttempt } from '../submissionAttempt.lib' +import { getBy } from '../utils.lib' +import type { + AnyInternalFormGroupApi, + InternalFormGroupApi, + InternalGroupValidatorInstance, +} from './FormGroupApi.lib' +import type { DeepKeys, DeepValue } from '../deep-keys.public' +import type { ClientValidationRun } from '../pipeline/validation/clientValidation.lib' +import type { FormGroupValidators } from '../pipeline/validation/validation.public' +import type { SubmissionOutcome } from '../submissionAttempt.lib' +import type { FormattedValidationError } from '../validation/results/results.public' +import type { ScopedErrorTypes } from '../validation/state.public' + +export interface FormGroupValidationOutcome { + errors: Array> + run: ClientValidationRun | null +} + +async function runFormGroupSubmissionCallbacks( + group: AnyInternalFormGroupApi, + submission: SubmissionAttempt>, + validationOutcome: FormGroupValidationOutcome, +): Promise>> { + const value = submission.value + const invalidContext = { + value, + formApi: group.form, + groupApi: group, + } as never + + if (validationOutcome.errors.length > 0) { + await group._options.onSubmitInvalid?.(invalidContext) + return { errors: validationOutcome.errors, successful: false } + } + + const submitContext = { + value, + formApi: group.form, + groupApi: group, + validatorOutputs: + validationOutcome.run?.collector.getOutputs( + validationOutcome.run.validatorCount, + ) ?? [], + } as never + + try { + await group._options.onSubmit?.(submitContext) + } catch (error) { + if (!submission.isActive()) return { errors: [], successful: false } + await group._options.onSubmitInvalid?.(invalidContext) + if (!submission.isActive()) return { errors: [], successful: false } + throw error + } + + return { errors: [], successful: true } +} + +export function handleFormGroupSubmit< + TFormData, + TGroupName extends DeepKeys, + TGroupValue extends DeepValue, + TGroupValidators extends FormGroupValidators, + TFormError extends ScopedErrorTypes, +>( + group: InternalFormGroupApi< + TFormData, + TGroupName, + TGroupValue, + TGroupValidators, + TFormError + >, + submissions: Set>>, +): Promise>> { + const values = group.form.state.values + const submission = new SubmissionAttempt< + FormattedValidationError + >({ + values, + value: getBy(values, group.name), + node: group._groupField, + validators: group._validatorInstances, + owner: { + submissions, + submissionAttempts: group._submissionAttempts, + isSubmitting: group._isSubmitting, + isSubmitSuccessful: group._isSubmitSuccessful, + }, + }) + submission.start({ + validate: () => group._validate('submit', { submission }), + callbacks: (validation) => + runFormGroupSubmissionCallbacks(group, submission, validation), + }) + return submission.promise +} diff --git a/packages/form-core/src/ListenerInstance.lib.ts b/packages/form-core/src/ListenerInstance.lib.ts deleted file mode 100644 index bf92ab029..000000000 --- a/packages/form-core/src/ListenerInstance.lib.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { LiteDebouncer } from '@tanstack/pacer-lite' -import type { AnyFieldListener } from './listeners.public' -import type { AnyFormListener } from './FormApi/formListeners.public' - -export type InternalListenerDefinition = AnyFormListener | AnyFieldListener - -export type ListenerInstanceDebouncedFn = (...args: Array) => any - -export type AnyInternalListenerInstance< - TDebouncedFn extends ListenerInstanceDebouncedFn = - ListenerInstanceDebouncedFn, -> = InternalListenerInstance - -export type InternalListenerInstances< - TDefinition extends InternalListenerDefinition, - TOwner, - TWatchedField = unknown, -> = Array> | null - -export interface ReconcileListenerInstancesOptions< - TDefinition extends InternalListenerDefinition, - TOwner, - TWatchedField = unknown, -> { - definitions: ReadonlyArray | null | undefined - previousDefinitions?: ReadonlyArray | null - instances: InternalListenerInstances - owner: TOwner - onBeforeDispose?: ( - instance: InternalListenerInstance, - ) => void -} - -/** Runtime state owned by one installed listener occurrence. */ -export class InternalListenerInstance< - TDefinition extends InternalListenerDefinition, - TOwner, - TWatchedField = unknown, - TDebouncedFn extends ListenerInstanceDebouncedFn = - ListenerInstanceDebouncedFn, -> { - /** The boundary that owns this listener. */ - readonly owner: TOwner - /** This listener's stable position within its owner's listener pipeline. */ - readonly index: number - /** The current listener definition associated with this stable instance. */ - definition: TDefinition - /** The lazily created debouncer for this listener's pending execution. */ - debouncer: LiteDebouncer | null = null - /** Resolved fields referenced by this listener's `watchFields` definition. */ - resolvedWatchFields: Map | null = null - /** Number of definition updates applied while preserving this instance. */ - revision = 0 - /** Whether this listener has been permanently disposed. */ - disposed = false - - constructor({ - definition, - owner, - index = 0, - }: { - definition: TDefinition - owner: TOwner - index?: number - }) { - this.definition = definition - this.owner = owner - this.index = index - } - - /** Replaces the definition while preserving this instance and runtime state. */ - updateDefinition(definition: TDefinition): void { - if (this.disposed) return - - this.definition = definition - this.revision++ - } - - /** Returns this listener's debouncer, creating it on first use. */ - 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 - } - - /** Associates a configured watched-field name with its resolved field. */ - setResolvedWatchField(name: string, field: TWatchedField): void { - if (this.disposed) return - - if (!this.resolvedWatchFields) { - this.resolvedWatchFields = new Map() - } - this.resolvedWatchFields.set(name, field) - } - - /** Removes a resolved watched field. */ - deleteResolvedWatchField(name: string): void { - if (this.disposed) return - - this.resolvedWatchFields?.delete(name) - if (this.resolvedWatchFields?.size === 0) { - this.resolvedWatchFields = null - } - } - - /** Cancels transient execution while preserving identity and dependencies. */ - resetRuntime(): void { - if (this.disposed) return - - this.debouncer?.cancel() - this.debouncer = null - } - - /** Permanently disposes this listener and releases its runtime state. */ - dispose(onBeforeDispose?: (instance: this) => void): void { - if (this.disposed) return - - onBeforeDispose?.(this) - this.resetRuntime() - this.resolvedWatchFields = null - this.disposed = true - } -} - -/** Correlates listener definitions with stable runtime instances by slot. */ -export function reconcileListenerInstances< - TDefinition extends InternalListenerDefinition, - TOwner, - TWatchedField = unknown, ->({ - definitions, - previousDefinitions, - instances, - owner, - onBeforeDispose, -}: ReconcileListenerInstancesOptions< - TDefinition, - TOwner, - TWatchedField ->): InternalListenerInstances { - if ( - previousDefinitions !== undefined && - (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) - ) { - console.warn( - 'TanStack Form: The length of the listener array should not change after initialization', - ) - } - - if (!definitions || definitions.length === 0) { - instances?.forEach((instance) => instance.dispose(onBeforeDispose)) - return null - } - - const nextInstances = instances ?? [] - - definitions.forEach((definition, index) => { - const instance = nextInstances[index] - - if (instance) { - instance.updateDefinition(definition) - } else { - nextInstances[index] = new InternalListenerInstance({ - definition, - owner, - index, - }) - } - }) - - for (let index = definitions.length; index < nextInstances.length; index++) { - nextInstances[index]?.dispose(onBeforeDispose) - } - nextInstances.length = definitions.length - - return nextInstances -} diff --git a/packages/form-core/src/ValidationSourceInstance.lib.ts b/packages/form-core/src/ValidationSourceInstance.lib.ts deleted file mode 100644 index ae114d461..000000000 --- a/packages/form-core/src/ValidationSourceInstance.lib.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - addValidationErrorTarget, - deleteValidationErrorTarget, -} from './pipeline/ValidationInstance.lib' -import type { ValidatorScope } from './validation.public' -import type { ValidationInstance } from './pipeline/ValidationInstance.lib' - -/** 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 = ValidationInstance - -/** Stable identity and ordering metadata for one internal validation source. */ -export class InternalValidationSourceInstance< - TOwner, - TErrorTarget = unknown, -> implements ValidationInstance { - /** 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 { - addValidationErrorTarget(this, errorTarget) - } - - /** Stops tracking a target that no longer receives errors from this source. */ - deleteErrorTarget(errorTarget: TErrorTarget): void { - deleteValidationErrorTarget(this, errorTarget) - } - - /** 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 deleted file mode 100644 index 07d1bdcde..000000000 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { LiteDebouncer } from '@tanstack/pacer-lite' -import { validationSourceScopes } from './ValidationSourceInstance.lib' -import { - addValidationErrorTarget, - deleteValidationErrorTarget, -} from './pipeline/ValidationInstance.lib' -import { ValidatorExecutor } from './pipeline/validatorExecutor.lib' -import type { - FieldValidator, - FormGroupValidator, - ValidatorScope, -} from './validation.public' -import type { FormValidator } from './validation/formValidators.public' -import type { FieldErrorTarget } from './validation/state.public' -import type { - ValidationInstance, - ValidationInstanceScopePriority, -} from './pipeline/ValidationInstance.lib' - -type GetDefinition = { - form: FormValidator - group: FormGroupValidator - field: FieldValidator -}[TScope] - -export type ValidatorInstanceDebouncedFn = (...args: Array) => any - -export type AnyInternalValidatorInstance< - TDebouncedFn extends ValidatorInstanceDebouncedFn = - ValidatorInstanceDebouncedFn, -> = InternalValidatorInstance - -export interface InternalValidatorInstanceOptions< - out TOwner, - out TScope extends ValidatorScope, -> { - owner: TOwner - scope: TScope - definition: GetDefinition - index?: number -} - -/** Stable runtime instances correlated with validator definitions by slot. */ -export type InternalValidatorInstances< - TScope extends ValidatorScope, - TOwner, - TErrorTarget = unknown, - TWatchedField = unknown, - TOutput = unknown, -> = Array< - InternalValidatorInstance< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput - > -> | null - -export interface ReconcileValidatorInstancesOptions< - TScope extends ValidatorScope, - TOwner, - TErrorTarget = unknown, - TWatchedField = unknown, - TOutput = unknown, -> { - /** The latest validator definitions installed on the owner. */ - definitions: ReadonlyArray> | null | undefined - /** - * The definitions installed before an update. - * - * Omit during initialization. Pass `null` when updating an owner that - * previously had no validators. - */ - previousDefinitions?: ReadonlyArray> | null - /** The owner's currently installed instances, if it has any. */ - instances: InternalValidatorInstances< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput - > - /** The validation boundary that owns every reconciled instance. */ - owner: TOwner - /** The form, group, or field scope shared by the reconciled instances. */ - scope: TScope - /** Cleans owner-specific state before an instance is permanently disposed. */ - onBeforeDispose?: ( - instance: InternalValidatorInstance< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput - >, - ) => void -} - -/** - * Runtime state owned by one installed validator occurrence. - */ -export class InternalValidatorInstance< - TScope extends ValidatorScope, - TOwner, - TErrorTarget = unknown, - TWatchedField = unknown, - TOutput = unknown, - TDebouncedFn extends ValidatorInstanceDebouncedFn = - ValidatorInstanceDebouncedFn, -> - extends ValidatorExecutor> - implements ValidationInstance -{ - /** The validation boundary that owns this source. */ - readonly owner: TOwner - /** Numeric priority used to order errors across validation scopes. */ - readonly scope: ValidationInstanceScopePriority - /** Targets currently receiving errors routed from this source. */ - errorTargets: Set | null = null - /** Whether this source has been permanently disposed. */ - disposed = false - /** 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 schema output or branded payload assigned by the latest form or group submit. - * - * Submit pipelines cancel prior executions and clear this before evaluating - * their validators. Field and non-submit pipelines do not assign it. Consult - * `hasOutput` because `undefined` can itself be a stored output. - */ - output: TOutput | undefined - /** Whether the current submit pipeline assigned `output`. */ - hasOutput = false - /** - * Resolved fields referenced by this validator's `watchFields` definition. - * - * The map is allocated on first use and returns to `null` when empty. - */ - resolvedWatchFields: Map | null = null - /** Whether this occurrence has already run its mount validation. */ - didRunOnMount = false - /** - * Number of definition updates applied while preserving this instance. - * - * Assigning the same definition again still advances the revision. - */ - revision = 0 - fieldErrorTarget: FieldErrorTarget - /** Creates the runtime state for one installed validator occurrence. */ - constructor({ - definition, - owner, - scope, - index = 0, - }: InternalValidatorInstanceOptions) { - super({ definition, validatorScope: scope, index }) - this.owner = owner - this.scope = validationSourceScopes[scope] - this.fieldErrorTarget = 'field' - if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { - this.fieldErrorTarget = definition.fieldErrorTarget - } - } - - /** Records a target receiving errors from this source. */ - addErrorTarget(errorTarget: TErrorTarget): void { - addValidationErrorTarget(this, errorTarget) - } - - /** Stops tracking a target that no longer receives errors from this source. */ - deleteErrorTarget(errorTarget: TErrorTarget): void { - deleteValidationErrorTarget(this, errorTarget) - } - - /** - * 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: GetDefinition): void { - if (this.disposed) return - - this.definition = definition - this.fieldErrorTarget = 'field' - if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { - this.fieldErrorTarget = definition.fieldErrorTarget - } - this.revision++ - } - - /** - * Installs the controller for the next active execution. - * - * A different previously installed controller is aborted before replacement. - * Reinstalling the same controller or calling this after disposal has no effect. - */ - setAbortController(abortController: AbortController): void { - if (this.disposed) return - if (this.abortController === abortController) return - - this.abortController?.abort() - this.abortController = abortController - } - - /** - * Clears an active controller only if it is still the installed controller. - * - * The identity check prevents completion of an older execution from clearing - * the controller of a newer one. This method does not abort the controller. - */ - clearAbortController(abortController: AbortController): void { - if (this.disposed) return - if (this.abortController !== abortController) return - - this.abortController = null - } - - /** - * Returns this validator's debouncer, creating it on first use. - * - * An existing debouncer is reused with its callback and wait duration updated. - * Returns `null` after disposal. - */ - getOrCreateDebouncer( - fn: TDebouncedFn, - wait: number, - ): LiteDebouncer | null { - if (this.disposed) return null - - let debouncer = this.debouncer - if (!debouncer) { - debouncer = new LiteDebouncer(fn, { wait }) - this.debouncer = debouncer - } else { - debouncer.fn = fn - debouncer.options.wait = wait - } - - return debouncer - } - - /** - * Stores a submit pipeline's schema output or branded payload when present. - * - * A result without an output leaves the current state unchanged. An - * explicit `undefined` is still considered a stored output. The operation is - * ignored after disposal. - * - * @param result - The accepted result produced by the validator pipeline. - */ - setOutput(result: { output: TOutput | null; hasOutput: boolean }): void { - if (this.disposed || !result.hasOutput) return - - this.output = result.output as TOutput - this.hasOutput = true - } - - /** Clears the stored output and its presence marker. */ - clearOutput(): void { - if (this.disposed) return - - this._clearOutput() - } - - /** - * Associates a configured watched-field name with its resolved field. - * - * The backing map is allocated lazily, and an existing entry for `name` is - * replaced. The operation is ignored after disposal. - */ - setResolvedWatchField(name: string, field: TWatchedField): void { - if (this.disposed) return - - if (!this.resolvedWatchFields) { - this.resolvedWatchFields = new Map() - } - this.resolvedWatchFields.set(name, field) - } - - /** - * Removes a resolved watched field. - */ - deleteResolvedWatchField(name: string): void { - if (this.disposed) return - - this.resolvedWatchFields?.delete(name) - if (this.resolvedWatchFields?.size === 0) { - this.resolvedWatchFields = null - } - } - - /** Marks mount validation as completed for this occurrence. */ - markMountValidationRan(): void { - if (this.disposed) return - - this.didRunOnMount = true - } - - /** Allows a lifecycle owner to run mount validation again after remounting. */ - resetMountValidation(): void { - if (this.disposed) return - - this.didRunOnMount = false - } - - /** - * Cancels the runner task, aborts active execution, and cancels debounced work. - * - * 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._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 validator-specific runtime state during reset or disposal. */ - protected _resetRuntime(): void { - this._cancelExecution() - this._clearOutput() - } - - /** Releases validator-only collections and mount state during disposal. */ - protected _disposeRuntime(): void { - this._resetRuntime() - this.resolvedWatchFields = null - this.didRunOnMount = false - } - - /** Cancels and releases execution resources without checking disposal state. */ - private _cancelExecution(): void { - this.cancelCurrentExecution?.() - this.abortController?.abort() - this.abortController = null - this.debouncer?.cancel() - this.debouncer = null - } - - /** Clears the schema-output value and presence marker as one operation. */ - private _clearOutput(): void { - this.output = undefined - this.hasOutput = 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< - TScope extends ValidatorScope, - TOwner, - TErrorTarget = unknown, - TWatchedField = unknown, - TOutput = unknown, ->({ - definitions, - previousDefinitions, - instances, - owner, - scope, - onBeforeDispose, -}: ReconcileValidatorInstancesOptions< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput ->): InternalValidatorInstances< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput -> { - if ( - previousDefinitions !== undefined && - (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) - ) { - console.warn( - 'TanStack Form: The length of the validator array should not change after initialization', - ) - } - - if (!definitions || definitions.length === 0) { - instances?.forEach((instance) => { - instance.dispose(onBeforeDispose) - }) - return null - } - - const nextInstances = instances ?? [] - - definitions.forEach((definition, index) => { - const instance = nextInstances[index] - - if (instance) { - instance.updateDefinition(definition) - } else { - nextInstances[index] = new InternalValidatorInstance< - TScope, - TOwner, - TErrorTarget, - TWatchedField, - TOutput - >({ definition, owner, scope, index }) - } - }) - - for (let index = definitions.length; index < nextInstances.length; index++) { - const instance = nextInstances[index] - if (!instance) continue - - instance.dispose(onBeforeDispose) - } - nextInstances.length = definitions.length - - return nextInstances -} diff --git a/packages/form-core/src/defaultOptions.public.ts b/packages/form-core/src/defaultOptions.public.ts index 8896d89d2..96c55714e 100644 --- a/packages/form-core/src/defaultOptions.public.ts +++ b/packages/form-core/src/defaultOptions.public.ts @@ -1,8 +1,11 @@ -import type { FieldApiOptions } from './FieldApi/FieldApi.public' import type { AnyFormOptions } from './FormApi/formOptions.public' -import type { FormGroupOptions } from './FormGroupApi/FormGroupApi.public' -import type { FieldValidators, FormGroupValidators } from './validation.public' +import type { FieldApiOptions } from './FieldApi/FieldApi.public' +import type { + FieldValidators, + FormGroupValidators, +} from './pipeline/validation/validation.public' import type { ScopedErrorTypes } from './validation/state.public' +import type { FormGroupOptions } from './FormGroupApi/FormGroupApi.public' /** * Determines how a supplied usage-site listener array combines with default diff --git a/packages/form-core/src/devtoolsBridge.lib.ts b/packages/form-core/src/devtoolsBridge.lib.ts index 97279603e..b57c74077 100644 --- a/packages/form-core/src/devtoolsBridge.lib.ts +++ b/packages/form-core/src/devtoolsBridge.lib.ts @@ -1,31 +1,14 @@ +import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' import type { AnyInternalFormApi } from './FormApi/FormApi.lib' -import type { - AnyInternalFieldApi, - InternalFieldListenerInstance, - InternalFieldValidatorInstance, -} from './FieldApi/FieldApi.lib' /** * A listener or validator dependency edge affected by reconciliation. */ -interface BaseFieldDependencyChange { - sourceField: AnyInternalFieldApi +export interface FieldDependencyChange { + sourceFieldName: string watchingField: AnyInternalFieldApi } -export interface FieldListenerDependencyChange extends BaseFieldDependencyChange { - kind: 'listener' - listenerInstance: InternalFieldListenerInstance -} - -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/index.ts b/packages/form-core/src/index.ts index f99059c2a..4f88a99a0 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -3,19 +3,20 @@ export * from './FormApi/FormApi.public' export * from './FormApi/FormApiArrayMethods.types.public' export * from './FormApi/FormApiFieldMethods.types.public' export * from './FormApi/errorVisibility.public' -export * from './FormApi/formListeners.public' +export * from './pipeline/listeners/formListeners.public' export * from './FormApi/formOptions.public' export * from './FormApi/formServerState.public' export * from './FormApi/formSubmit.public' export * from './FormGroupApi/FormGroupApi.public' export * from './FieldGroup/fieldGroupTypes.public' export * from './types.public' -export * from './validation.public' -export * from './validation/formValidators.public' +export * from './pipeline/validation/validation.public' +export * from './validation/results/results.public' export * from './validation/state.public' -export * from './standardSchema.public' -export * from './listeners.public' -export * from './events.public' +export * from './pipeline/validation/formValidators.public' +export * from './validation/results/standardSchema.public' +export * from './pipeline/listeners/listeners.public' +export * from './pipeline/events.public' export * from './utils.public' export * from './deep-keys.public' export * from './defaultOptions.public' diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index 20d443b65..b14a6e381 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -1,4 +1,6 @@ export * from './FieldApi/FieldApi.lib' +export * from './FieldApi/fieldOptions.lib' +export * from './FieldApi/fieldEvents.lib' export * from './FieldApi/fieldTraversal.lib' export * from './FieldApi/fieldState.lib' export * from './FormApi/FormApi.lib' @@ -7,13 +9,16 @@ export * from './FieldGroup/FieldGroupApi.lib' export * from './utils.lib' export * from './types.lib' export * from './FieldApi/RootFieldApi.lib' -export * from './validation' -export * from './ValidatorInstance.lib' -export * from './ListenerInstance.lib' -export * from './ValidationSourceInstance.lib' -export * from './listeners.lib' +export * from './validation/errorStorage.lib' +export * from './validation/results/errors.lib' +export * from './pipeline/validation/ValidatorInstance.lib' +export * from './pipeline/runTracker.lib' +export * from './pipeline/listeners/ListenerInstance.lib' +export * from './pipeline/listeners/listeners.lib' export * from './FieldApi/linked-fields.lib' -export * from './standardSchema.lib' +export * from './validation/results/parseIssues.lib' +export * from './pipeline/validation/standardSchema.lib' export * from './devtoolsBridge.lib' -export * from './ssr.lib' +export * from './FormApi/formServerState.lib' +export * from './pipeline/validation/serverValidation.lib' export * from './defaultOptions.lib' diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts deleted file mode 100644 index d1dd1365f..000000000 --- a/packages/form-core/src/listeners.lib.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { AnyInternalFormApi } from './FormApi/FormApi.lib' -import type { - AnyFormListener, - FormListenerContext, -} from './FormApi/formListeners.public' -import type { AnyInternalListenerInstance } from './ListenerInstance.lib' -import type { - AnyFieldListener, - FieldListenerContext, - FieldListenerTriggers, - Listener, - ListenerDebounceFn, - ListenerPredicateFn, - ListenerTriggerOption, -} from './listeners.public' - -type ListenerEvent = FieldListenerTriggers - -type FormInputContext = { - event: ListenerEvent - triggerFieldApi?: AnyInternalFieldApi - formApi: AnyInternalFormApi -} - -type FieldInputContext = { - event: ListenerEvent - fieldApi: AnyInternalFieldApi - formApi: AnyInternalFormApi -} - -type InputContext = FormInputContext | FieldInputContext -type ListenerContext = - FormListenerContext | FieldListenerContext -type AnyListener = AnyFormListener | AnyFieldListener - -function isFormContext(ctx: InputContext): ctx is FormInputContext { - return 'triggerFieldApi' in ctx -} - -function getContextValue(context: InputContext) { - return isFormContext(context) - ? context.formApi.state.values - : context.fieldApi.value -} - -function getEnabledState( - booleanOrFn: boolean | ListenerPredicateFn, - context: InputContext, -): boolean { - if (typeof booleanOrFn === 'boolean') return booleanOrFn - - return booleanOrFn({ - triggerFieldApi: isFormContext(context) - ? context.triggerFieldApi - : context.fieldApi, - formApi: context.formApi, - value: getContextValue(context), - }) -} - -function getDebounceMs( - numberOrFn: number | ListenerDebounceFn, - context: InputContext, -): number { - if (typeof numberOrFn === 'number') return numberOrFn - - return numberOrFn({ - triggerFieldApi: isFormContext(context) - ? context.triggerFieldApi - : context.fieldApi, - formApi: context.formApi, - value: getContextValue(context), - }) -} - -function isListenerTriggerEnabled( - trigger: ListenerTriggerOption, - 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 shouldRunListener( - listener: Listener, - context: InputContext, -): boolean { - return listener.triggers.some((trigger) => - isListenerTriggerEnabled(trigger, context), - ) -} - -function getListenerDebounceMs( - listener: Listener, - context: InputContext, -): number { - if (context.event === 'submit') return 0 - - const { triggerDebounceMs = 0 } = listener - - return getDebounceMs(triggerDebounceMs, context) -} - -function executeListener( - listener: AnyListener, - context: ListenerContext, -): void { - Promise.resolve(listener.run(context as never)).catch((error) => { - console.error('Listener threw an error:', error) - }) -} - -function runListener({ - listenerInstance, - context, - getContext, -}: { - listenerInstance: AnyInternalListenerInstance - context: TContext - getContext: (inputContext: TContext) => ListenerContext -}): void { - const listener = listenerInstance.definition as AnyListener - const debounceMs = getListenerDebounceMs(listener, context) - const listenerContext = getContext(context) - - if (debounceMs <= 0) { - executeListener(listener, listenerContext) - return - } - - const debouncer = listenerInstance.getOrCreateDebouncer( - (ctx: ListenerContext) => executeListener(listenerInstance.definition, ctx), - debounceMs, - ) - - debouncer?.maybeExecute(listenerContext) -} - -function runListenerPipeline({ - pipeline, - context, - getContext, - listenerInstancesToRun = null, -}: { - pipeline: ReadonlyArray - context: TContext - getContext: (inputContext: TContext) => ListenerContext - listenerInstancesToRun?: ReadonlySet | null -}): void { - pipeline.forEach((listenerInstance) => { - if ( - listenerInstancesToRun && - !listenerInstancesToRun.has(listenerInstance) - ) { - return - } - - const listener = listenerInstance.definition as AnyListener - if (!shouldRunListener(listener, context)) { - return - } - - runListener({ - listenerInstance, - context, - getContext, - }) - }) -} - -interface FormListenerPipelineArgs { - pipeline: ReadonlyArray - context: FormInputContext -} - -export function runFormListenerPipeline({ - pipeline, - context, -}: FormListenerPipelineArgs): void { - return runListenerPipeline({ - pipeline, - context, - getContext: (ctx) => ({ - formApi: ctx.formApi, - triggerFieldApi: ctx.triggerFieldApi, - value: ctx.formApi.state.values, - }), - }) -} - -interface FieldListenerPipelineArgs { - pipeline: ReadonlyArray - context: FieldInputContext - /** - * @private - * When an incoming watched field notifies, we should only run listeners - * that are actually interested in it. - */ - listenerInstancesToRun: ReadonlySet | null -} - -export function runFieldListenerPipeline({ - pipeline, - context, - listenerInstancesToRun, -}: FieldListenerPipelineArgs): void { - if (context.fieldApi._isKilled) return - - return runListenerPipeline({ - pipeline, - context, - listenerInstancesToRun, - getContext: (ctx) => ({ - value: ctx.fieldApi.value, - fieldApi: context.fieldApi, - formApi: ctx.formApi, - }), - }) -} diff --git a/packages/form-core/src/pipeline/ValidationInstance.lib.ts b/packages/form-core/src/pipeline/ValidationInstance.lib.ts deleted file mode 100644 index e578fa3e4..000000000 --- a/packages/form-core/src/pipeline/ValidationInstance.lib.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** Error ordering priority: field, group, form, then onSubmit. */ -export type ValidationInstanceScopePriority = 0 | 1 | 2 | 3 - -/** Shared contract for executing and non-executing validation sources. */ -export interface ValidationInstance { - /** The field, group, or form that owns this source. */ - readonly owner: TOwner - /** Numeric priority used to order errors across validation scopes. */ - readonly scope: ValidationInstanceScopePriority - /** Original position within the owning scope, used to order its errors. */ - readonly index: number - /** Targets currently storing this source's errors, or `null` when empty. */ - errorTargets: Set | null - /** Whether this source has been permanently disposed. */ - disposed: boolean - /** Records a target receiving errors from this source. */ - addErrorTarget(errorTarget: TErrorTarget): void - /** Removes a target reference once it no longer stores this source's errors. */ - deleteErrorTarget(errorTarget: TErrorTarget): void - /** Releases transient state and target references while preserving identity. */ - resetRuntime(): void - /** Permanently releases source state, calling `onBeforeDispose` before cleanup. */ - dispose(onBeforeDispose?: (instance: this) => void): void -} - -/** Records an error target unless the validation instance has been disposed. */ -export function addValidationErrorTarget( - instance: ValidationInstance, - errorTarget: TErrorTarget, -): void { - if (instance.disposed) return - - if (!instance.errorTargets) { - instance.errorTargets = new Set() - } - instance.errorTargets.add(errorTarget) -} - -/** Removes a target unless disposed and resets an empty target set to `null`. */ -export function deleteValidationErrorTarget( - instance: ValidationInstance, - errorTarget: TErrorTarget, -): void { - if (instance.disposed) return - - instance.errorTargets?.delete(errorTarget) - if (instance.errorTargets?.size === 0) { - instance.errorTargets = null - } -} diff --git a/packages/form-core/src/events.public.ts b/packages/form-core/src/pipeline/events.public.ts similarity index 100% rename from packages/form-core/src/events.public.ts rename to packages/form-core/src/pipeline/events.public.ts diff --git a/packages/form-core/src/pipeline/listeners/ListenerInstance.lib.ts b/packages/form-core/src/pipeline/listeners/ListenerInstance.lib.ts new file mode 100644 index 000000000..39c5ce2cf --- /dev/null +++ b/packages/form-core/src/pipeline/listeners/ListenerInstance.lib.ts @@ -0,0 +1,123 @@ +import { PipelineInstance } from '../pipelineInstance.lib' +import type { AnyFormListener } from './formListeners.public' +import type { AnyFieldListener } from './listeners.public' +import type { ListenerContext } from './listenerContext.lib' +import type { Awaitable } from '../../types.public' + +export type ListenerDefinition = AnyFormListener | AnyFieldListener + +type ListenerScope = 'form' | 'field' + +type GetDefinition = { + form: AnyFormListener + field: AnyFieldListener +}[TScope] + +interface ListenerRunInput { + createContext: (signal: AbortSignal) => ListenerContext +} + +export type AnyListenerInstance = ListenerInstance + +export type ListenerInstances = Array< + ListenerInstance +> | null + +export interface UpdateListenerInstancesOptions { + definitions: ReadonlyArray> | null | undefined + previousDefinitions?: ReadonlyArray> | null + instances: ListenerInstances + scope: TScope + onBeforeDispose?: (instance: ListenerInstance) => void +} + +/** Runtime state owned by one installed listener occurrence. */ +export class ListenerInstance< + TScope extends ListenerScope, +> extends PipelineInstance { + /** Updates installed listener instances by slot. */ + static update({ + definitions, + previousDefinitions, + instances, + scope, + onBeforeDispose, + }: UpdateListenerInstancesOptions): ListenerInstances { + if ( + previousDefinitions && + previousDefinitions.length !== (definitions?.length ?? 0) + ) { + console.warn( + 'TanStack Form: The length of the listener array should not change after initialization', + ) + } + + if (!definitions || definitions.length === 0) { + instances?.forEach((instance) => instance.dispose(onBeforeDispose)) + return null + } + + const nextInstances = instances ?? [] + + definitions.forEach((definition, index) => { + const instance = nextInstances[index] + + if (instance) { + instance.updateDefinition(definition) + } else { + nextInstances[index] = new ListenerInstance({ + definition, + scope, + }) + } + }) + + return nextInstances + } + + /** The current listener definition associated with this stable instance. */ + definition: GetDefinition + /** Whether this listener has been permanently disposed. */ + disposed = false + + constructor({ + definition, + }: { + definition: GetDefinition + scope: TScope + }) { + super() + this.definition = definition + } + + /** Replaces the definition while preserving this instance and runtime state. */ + updateDefinition(definition: GetDefinition): void { + if (this.disposed) return + + this.definition = definition + } + + execute(input: ListenerRunInput, signal: AbortSignal): Awaitable { + if (this.disposed || signal.aborted) return + + const definition = this.definition + const context = input.createContext(signal) + return definition.run(context as never) + } + + /** Cancels transient execution while preserving identity and dependencies. */ + resetRuntime(): void { + if (this.disposed) return + + this.cancelCurrentExecution?.() + } + + /** Permanently disposes this listener and releases its runtime state. */ + dispose(onBeforeDispose?: (instance: this) => void): void { + if (this.disposed) return + + onBeforeDispose?.(this) + this.resetRuntime() + this.disposed = true + } +} diff --git a/packages/form-core/src/FormApi/formListeners.public.ts b/packages/form-core/src/pipeline/listeners/formListeners.public.ts similarity index 60% rename from packages/form-core/src/FormApi/formListeners.public.ts rename to packages/form-core/src/pipeline/listeners/formListeners.public.ts index bf0c63262..c74908209 100644 --- a/packages/form-core/src/FormApi/formListeners.public.ts +++ b/packages/form-core/src/pipeline/listeners/formListeners.public.ts @@ -1,8 +1,8 @@ -import type { AnyFieldApi } from '../FieldApi/FieldApi.public' -import type { Listener } from '../listeners.public' import type { ClientSideEvents, PreprocessEvents } from '../events.public' -import type { ScopedErrorTypes } from '../validation/state.public' -import type { FormApi } from './FormApi.public' +import type { ScopedErrorTypes } from '../../validation/state.public' +import type { FormApi } from '../../FormApi/FormApi.public' +import type { AnyFieldApi } from '../../FieldApi/FieldApi.public' +import type { Listener } from './listeners.public' export type FormListenerTriggers = ClientSideEvents | PreprocessEvents | 'reset' @@ -11,8 +11,16 @@ export interface FormListenerContext< TFormError extends ScopedErrorTypes, > { formApi: FormApi + /** The form's values when the callback begins executing. */ value: TFormData triggerFieldApi?: AnyFieldApi + /** + * Aborted when this invocation is canceled or replaced by a newer one. + * + * Use this signal for cooperative early exit. Return asynchronous work's + * promise to keep it attached to this invocation's lifetime. + */ + signal: AbortSignal } export type FormListenerFn = ( diff --git a/packages/form-core/src/pipeline/listeners/listenerContext.lib.ts b/packages/form-core/src/pipeline/listeners/listenerContext.lib.ts new file mode 100644 index 000000000..095e507a8 --- /dev/null +++ b/packages/form-core/src/pipeline/listeners/listenerContext.lib.ts @@ -0,0 +1,68 @@ +import type { AnyInternalFieldApi } from '../../FieldApi/FieldApi.lib' +import type { AnyInternalFormApi } from '../../FormApi/FormApi.lib' +import type { FormListenerContext } from './formListeners.public' +import type { + FieldListenerContext, + ListenerPredicateContext, +} from './listeners.public' + +export interface FormListenerContextInput { + triggerFieldApi?: AnyInternalFieldApi + formApi: AnyInternalFormApi +} + +export interface FieldListenerContextInput { + fieldApi: AnyInternalFieldApi + formApi: AnyInternalFormApi +} + +type InputContext = FormListenerContextInput | FieldListenerContextInput + +export type ListenerContext = + FormListenerContext | FieldListenerContext + +function isFormContext(ctx: InputContext): ctx is FormListenerContextInput { + return 'triggerFieldApi' in ctx +} + +function getContextValue(context: InputContext) { + return isFormContext(context) + ? context.formApi.state.values + : context.fieldApi.value +} + +export function createListenerPredicateContext( + context: InputContext, +): ListenerPredicateContext { + return { + triggerFieldApi: isFormContext(context) + ? context.triggerFieldApi + : context.fieldApi, + formApi: context.formApi, + value: getContextValue(context), + } +} + +export function createFormListenerContext( + input: FormListenerContextInput, + signal: AbortSignal, +): FormListenerContext { + return { + formApi: input.formApi, + triggerFieldApi: input.triggerFieldApi, + value: input.formApi.state.values, + signal, + } +} + +export function createFieldListenerContext( + input: FieldListenerContextInput, + signal: AbortSignal, +): FieldListenerContext { + return { + value: input.fieldApi.value, + fieldApi: input.fieldApi, + formApi: input.formApi, + signal, + } +} diff --git a/packages/form-core/src/pipeline/listeners/listeners.lib.ts b/packages/form-core/src/pipeline/listeners/listeners.lib.ts new file mode 100644 index 000000000..59859d496 --- /dev/null +++ b/packages/form-core/src/pipeline/listeners/listeners.lib.ts @@ -0,0 +1,135 @@ +import { watchesField } from '../../FieldApi/linked-fields.lib' +import { resolveDebounceMs, selectTriggers } from '../selection.lib' +import { runPipeline } from '../runPipeline.lib' +import { + createFieldListenerContext, + createFormListenerContext, + createListenerPredicateContext, +} from './listenerContext.lib' +import type { + FieldListenerContextInput, + FormListenerContextInput, + ListenerContext, +} from './listenerContext.lib' +import type { FieldListenerTriggers } from './listeners.public' +import type { + AnyListenerInstance, + ListenerDefinition, +} from './ListenerInstance.lib' +import type { PipelineEntry } from '../state.lib' + +interface FormInputContext extends FormListenerContextInput { + event: FieldListenerTriggers +} + +interface FieldInputContext extends FieldListenerContextInput { + event: FieldListenerTriggers +} + +type InputContext = FormInputContext | FieldInputContext + +function runListenerPipeline({ + pipeline, + context, + getContext, + watchedFieldName, +}: { + pipeline: ReadonlyArray + context: TContext + getContext: (inputContext: TContext, signal: AbortSignal) => ListenerContext + watchedFieldName?: string +}): void { + const createPredicateContext = () => createListenerPredicateContext(context) + const entries: Array> = [] + const disabled: Array = [] + for (const listenerInstance of pipeline) { + if (listenerInstance.disposed) continue + if ( + watchedFieldName !== undefined && + !watchesField(listenerInstance.definition.watchFields, watchedFieldName) + ) { + continue + } + + const entry: PipelineEntry = { + instance: listenerInstance, + } + try { + const listener = listenerInstance.definition as ListenerDefinition + const selection = selectTriggers( + listener.triggers, + context.event, + createPredicateContext, + ) + if (selection === 'skip') continue + if (selection === 'cancel') { + disabled.push(listenerInstance) + continue + } + + entry.debounceMs = resolveDebounceMs( + listener, + context.event, + createPredicateContext, + ) + } catch (error) { + entry.preparationFailure = { error } + } + entries.push(entry) + } + + try { + for (const listenerInstance of disabled) listenerInstance.resetRuntime() + const run = runPipeline({ + entries, + input: { createContext: (signal) => getContext(context, signal) }, + }) + void run.pending?.catch((error: unknown) => { + console.error('Listener threw an error:', error) + }) + } catch (error) { + console.error('Listener threw an error:', error) + } +} + +interface FormListenerPipelineArgs { + pipeline: ReadonlyArray + context: FormInputContext +} + +export function runFormListenerPipeline({ + pipeline, + context, +}: FormListenerPipelineArgs): void { + return runListenerPipeline({ + pipeline, + context, + getContext: createFormListenerContext, + }) +} + +interface FieldListenerPipelineArgs { + pipeline: ReadonlyArray + context: FieldInputContext + /** + * @private + * When an incoming watched field notifies, we should only run listeners + * that are actually interested in it. + */ + watchedFieldName?: string +} + +export function runFieldListenerPipeline({ + pipeline, + context, + watchedFieldName, +}: FieldListenerPipelineArgs): void { + if (context.fieldApi._isKilled) return + + return runListenerPipeline({ + pipeline, + context, + watchedFieldName, + getContext: createFieldListenerContext, + }) +} diff --git a/packages/form-core/src/listeners.public.ts b/packages/form-core/src/pipeline/listeners/listeners.public.ts similarity index 87% rename from packages/form-core/src/listeners.public.ts rename to packages/form-core/src/pipeline/listeners/listeners.public.ts index 6ca2ba027..13ece3718 100644 --- a/packages/form-core/src/listeners.public.ts +++ b/packages/form-core/src/pipeline/listeners/listeners.public.ts @@ -1,8 +1,8 @@ -import type { DeepKeys } from './deep-keys.public' -import type { FormApi } from './FormApi/FormApi.public' -import type { AnyFieldApi, FieldApi } from './FieldApi/FieldApi.public' -import type { ScopedErrorTypes } from './validation/state.public' -import type { FormListenerTriggers } from './FormApi/formListeners.public' +import type { FormListenerTriggers } from './formListeners.public' +import type { FormApi } from '../../FormApi/FormApi.public' +import type { AnyFieldApi, FieldApi } from '../../FieldApi/FieldApi.public' +import type { ScopedErrorTypes } from '../../validation/state.public' +import type { DeepKeys } from '../../deep-keys.public' /** * Events that can invoke a listener configured on a field. @@ -120,8 +120,8 @@ export type ListenerTriggerOption< * * The function is called after a trigger matches and its condition is enabled. * The returned number is interpreted as milliseconds; values less than or - * equal to `0` run immediately. The function is not called for `'submit'` - * events because they always run immediately. + * equal to `0` run immediately. Only `'change'` and `'blur'` events call this + * function; lifecycle and submission events run immediately. * * @example * ```ts @@ -151,10 +151,10 @@ export interface Listener< /** * The debounce delay in milliseconds before the listener runs. * - * A function recalculates the delay for each matching event. Repeated events - * restart the delay, and the listener receives the latest event context. - * Values less than or equal to `0` run immediately. `'submit'` events always - * run immediately. + * A function recalculates the delay for each matching change or blur event. + * Repeated matching events restart the delay. The callback receives current + * values when execution begins. Values less than or equal to `0` run + * immediately. Lifecycle and submission events always run immediately. * * @default 0 */ @@ -195,7 +195,7 @@ export interface FieldListenerContext< in out TFormData, in out TFormError extends ScopedErrorTypes, > { - /** The listening field's value captured when the event occurred. */ + /** The listening field's value when the callback begins executing. */ value: TFieldValue /** * The field that owns the listener. @@ -212,13 +212,21 @@ export interface FieldListenerContext< > /** The form that owns the field. */ formApi: FormApi + /** + * Aborted when this invocation is canceled or replaced by a newer one. + * + * Use this signal for cooperative early exit. Return asynchronous work's + * promise to keep it attached to this invocation's lifetime. + */ + signal: AbortSignal } /** * A callback invoked when a field listener runs. * - * The return value is ignored. A returned promise is not awaited, and a - * rejected promise is reported to the console. + * The return value is ignored. A returned promise is observed without delaying + * validation or submission. Failures are reported to the console while the + * invocation is active; failures after cancellation are suppressed. * * @example * ```ts @@ -297,8 +305,9 @@ export interface FieldListener< /** * Called when an enabled field trigger occurs. * - * The return value is ignored. A returned promise is not awaited, and a - * rejected promise is reported to the console. + * The return value is ignored. A returned promise is observed without delaying + * validation or submission. Failures are reported to the console while the + * invocation is active; failures after cancellation are suppressed. * * @example * ```ts diff --git a/packages/form-core/src/pipeline/baseInstance.lib.ts b/packages/form-core/src/pipeline/pipelineInstance.lib.ts similarity index 88% rename from packages/form-core/src/pipeline/baseInstance.lib.ts rename to packages/form-core/src/pipeline/pipelineInstance.lib.ts index 16936d638..3bcf7f752 100644 --- a/packages/form-core/src/pipeline/baseInstance.lib.ts +++ b/packages/form-core/src/pipeline/pipelineInstance.lib.ts @@ -1,6 +1,6 @@ import type { Awaitable } from '../types.public' -export abstract class BaseInstance { +export abstract class PipelineInstance { /** * Cancel this instance's current task, if present. */ diff --git a/packages/form-core/src/pipeline/runPipeline.lib.ts b/packages/form-core/src/pipeline/runPipeline.lib.ts index 0be769895..af2fbf512 100644 --- a/packages/form-core/src/pipeline/runPipeline.lib.ts +++ b/packages/form-core/src/pipeline/runPipeline.lib.ts @@ -1,9 +1,9 @@ import { isPromiseLike } from '../utils.lib' import { createPipelineState, createTaskState } from './state.lib' import { sleep } from './utils.lib' +import type { PipelineInstance } from './pipelineInstance.lib' import type { PipelineEntry, - PipelineInstance, PipelineOptions, PipelineOutput, PipelineResult, @@ -11,6 +11,7 @@ import type { PipelineState, TaskState, } from './state.lib' +import type { Awaitable } from '../types.public' function ensurePending( state: PipelineState, @@ -32,18 +33,21 @@ function ensurePending( */ function stopPipeline( state: PipelineState, + cancelled = false, ): boolean { if (!state.active) return false state.active = false - state.pendingTasks.forEach((cancel) => cancel()) + if (cancelled) state.onCancel?.() + state.pendingTasks.forEach((task) => task.cancel()) return true } function cancelPipeline( state: PipelineState, + notify = true, ): void { - const wasRunning = stopPipeline(state) + const wasRunning = stopPipeline(state, notify) if (wasRunning) { state.resolvePending?.() } @@ -59,6 +63,41 @@ function failPipeline( } } +function cancelPipelineInstances( + state: PipelineState, + instances: ReadonlySet, +): void { + if (!state.active) return + + const tasks = Array.from(state.pendingTasks).filter((task) => + instances.has(task.instance), + ) + let hasQueued = false + for ( + let index = state.nextEntryIndex; + index < state.entries.length; + index++ + ) { + const instance = state.entries[index]!.instance + if (instances.has(instance) && !state.cancelledInstances?.has(instance)) { + state.cancelledInstances ??= new Set() + state.cancelledInstances.add(instance) + hasQueued = true + } + } + if (!hasQueued && tasks.length === 0) return + + // Mark all queued entries before task cancellation can resume traversal. + state.onCancel?.() + for (const task of tasks) cancelTask(task, false) + if (state.processingEntries) return + try { + walkPipeline(state, [], true) + } catch (error) { + failPipeline(state, error) + } +} + function progressPipeline( state: PipelineState, ): void { @@ -68,7 +107,13 @@ function progressPipeline( if (state.nextEntryIndex === state.entries.length) { state.active = false - state.resolvePending?.() + if (state.errors.length > 0) { + state.rejectPending?.( + new AggregateError(state.errors, 'Pipeline execution failed'), + ) + } else { + state.resolvePending?.() + } return } @@ -99,7 +144,7 @@ function releaseTask( if (task.settled) return false task.settled = true - task.pipeline.pendingTasks.delete(task.cancel) + task.pipeline.pendingTasks.delete(task) if (task.instance.cancelCurrentExecution === task.cancel) { task.instance.cancelCurrentExecution = null } @@ -108,12 +153,35 @@ function releaseTask( function cancelTask( task: TaskState, + notify = true, ): void { if (!releaseTask(task)) return + if (notify && task.pipeline.active) task.pipeline.onCancel?.() task.controller.abort() progressPipeline(task.pipeline) } +function collectFailure( + state: PipelineState, + error: unknown, +): void { + ensurePending(state) + state.errors.push(error) + state.hasInvalidResult = true +} + +function failTask( + task: TaskState, + error: unknown, +): void { + if (!isTaskPending(task)) return + + const state = task.pipeline + collectFailure(state, error) + releaseTask(task) + progressPipeline(state) +} + function acceptResult( task: TaskState, value: PipelineOutput, @@ -122,7 +190,13 @@ function acceptResult( if (!isTaskPending(task)) return const state = task.pipeline - const invalid = state.isInvalid?.(value) ?? false + let invalid: boolean + try { + invalid = state.isInvalid?.(value) ?? false + } catch (error) { + failTask(task, error) + return + } if (!isTaskPending(task)) return state.hasInvalidResult ||= invalid @@ -149,9 +223,17 @@ function executeTask( const state = task.pipeline const instance = task.instance - const result = instance.execute(state.input, task.controller.signal) - if (!isPromiseLike(result)) { - acceptResult(task, result, syncBatch) + let result: Awaitable> + let promised: boolean + try { + result = instance.execute(state.input, task.controller.signal) + promised = isPromiseLike(result) + } catch (error) { + failTask(task, error) + return + } + if (!promised) { + acceptResult(task, result as PipelineOutput, syncBatch) return } @@ -167,8 +249,10 @@ function executeTask( } }, (error: unknown) => { - if (isTaskPending(task)) { - failPipeline(state, error) + try { + failTask(task, error) + } catch (progressError) { + failPipeline(state, progressError) } }, ) @@ -182,11 +266,12 @@ function startTask( const task = createTaskState(state, entry, cancelTask) const { instance, cancel } = task - const previousCancel = instance.cancelCurrentExecution - instance.cancelCurrentExecution = cancel + const previousCancel = + state.supersede === false ? null : instance.cancelCurrentExecution + if (state.supersede !== false) instance.cancelCurrentExecution = cancel + state.pendingTasks.add(task) previousCancel?.() - state.pendingTasks.add(cancel) if (!isTaskPending(task)) return @@ -210,12 +295,17 @@ function walkPipeline( while (state.active && state.nextEntryIndex < state.entries.length) { const entry = state.entries[state.nextEntryIndex]! + if (state.cancelledInstances?.has(entry.instance)) { + state.nextEntryIndex++ + continue + } + if (entry.bailIfInvalid) { if (state.hasPendingUpstream || state.pendingTasks.size > 0) { ensurePending(state) break } - if (state.hasInvalidResult) { + if (state.hasInvalidResult && !entry.preparationFailure) { state.nextEntryIndex++ state.onSkip?.(entry.instance) continue @@ -223,7 +313,13 @@ function walkPipeline( } state.nextEntryIndex++ - startTask(state, entry, batch) + if (entry.preparationFailure) { + // Preparation has already thrown, even if bail would skip the callback. + // Do not start a task or supersede older work for this occurrence. + collectFailure(state, entry.preparationFailure.error) + } else { + startTask(state, entry, batch) + } } } finally { state.processingEntries = false @@ -238,19 +334,23 @@ function walkPipeline( * Starts the supplied entries while preserving synchronous result delivery. * * @returns A run handle containing the initial `syncResults` batch, a `pending` - * promise for deferred work (`null` for an entirely synchronous run), and a - * `cancel` function. Later batches go to `onDeferredResults` before `pending` resolves. - * Cancellation resolves `pending` without delivering cancelled results. A - * deferred failure rejects it after cancelling the remaining tasks. + * completion promise for deferred work or occurrence failures, and cancellation + * handles. Later batches go to `onDeferredResults` before completion settles. + * Occurrence failures reject completion with an aggregate after eligible peers + * finish. Cancellation resolves completion without delivering cancelled results. * - * @throws Synchronous execution errors after cancelling work already started. + * @throws Synchronous orchestration errors after cancelling work already started. */ export function runPipeline( options: PipelineOptions, ): PipelineRun { const state = createPipelineState(options) - + const cancel = () => cancelPipeline(state) + const cancelInstances = (instances: ReadonlySet) => + cancelPipelineInstances(state, instances) try { + state.processingEntries = true + options.onStart?.(cancel, cancelInstances) if (isPromiseLike(state.upstreamInvalid)) { state.hasPendingUpstream = true void Promise.resolve(state.upstreamInvalid).then( @@ -263,17 +363,21 @@ export function runPipeline( (error: unknown) => failPipeline(state, error), ) } + state.processingEntries = false walkPipeline(state, state.syncResults, false) } catch (error) { // The caller has not received the handle: resolve any internal pending promise // during cleanup, then propagate the exception synchronously. - cancelPipeline(state) + state.processingEntries = false + cancelPipeline(state, false) throw error } return { syncResults: state.syncResults, pending: state.pending, - cancel: () => cancelPipeline(state), + hasPendingWork: state.active && state.pending !== null, + cancel, + cancelInstances, } } diff --git a/packages/form-core/src/pipeline/runTracker.lib.ts b/packages/form-core/src/pipeline/runTracker.lib.ts new file mode 100644 index 000000000..9f5e2b0b0 --- /dev/null +++ b/packages/form-core/src/pipeline/runTracker.lib.ts @@ -0,0 +1,30 @@ +import type { PipelineInstance } from './pipelineInstance.lib' +import type { PipelineRun } from './state.lib' + +type ValidationRun = Pick, 'cancel'> & + Partial, 'cancelInstances'>> + +/** Registers cancellation handles owned by one validation scope. */ +export class ValidationRunTracker { + private readonly runs = new Set() + + register = (run: ValidationRun): (() => void) => { + this.runs.add(run) + return () => { + this.runs.delete(run) + } + } + + cancelInstances = (instances: ReadonlySet): void => { + for (const run of Array.from(this.runs)) run.cancelInstances?.(instances) + } + + cancelAll = (): void => { + const runs = Array.from(this.runs) + this.runs.clear() + + for (const run of runs) { + run.cancel() + } + } +} diff --git a/packages/form-core/src/pipeline/selection.lib.ts b/packages/form-core/src/pipeline/selection.lib.ts new file mode 100644 index 000000000..c44173569 --- /dev/null +++ b/packages/form-core/src/pipeline/selection.lib.ts @@ -0,0 +1,54 @@ +export type PipelineSelection = 'run' | 'skip' | 'cancel' + +export type PipelineCondition = + boolean | ((context: TContext) => boolean) + +export type PipelineTrigger = + TEvent | { trigger: TEvent; when?: PipelineCondition } + +export function isConditionMet( + condition: PipelineCondition, + createPredicateContext?: () => TContext, +): boolean { + if (typeof condition === 'boolean') return condition + if (!createPredicateContext) return false + + return condition(createPredicateContext()) +} + +export function selectTriggers( + triggers: ReadonlyArray> | undefined, + event: string, + createPredicateContext?: () => TContext, +): PipelineSelection { + let matched = false + for (const trigger of triggers ?? []) { + if ((typeof trigger === 'string' ? trigger : trigger.trigger) !== event) + continue + + matched = true + if ( + typeof trigger === 'string' || + isConditionMet(trigger.when ?? true, createPredicateContext) + ) + return 'run' + } + return matched ? 'cancel' : 'skip' +} + +export function resolveDebounceMs( + definition: { + triggerDebounceMs?: number | ((context: TContext) => number) + }, + event: string, + createPredicateContext: () => TContext, +): number { + if (event !== 'change' && event !== 'blur') return 0 + if (definition.triggerDebounceMs === undefined) return 0 + + if (typeof definition.triggerDebounceMs === 'function') { + return definition.triggerDebounceMs(createPredicateContext()) + } else { + return definition.triggerDebounceMs + } +} diff --git a/packages/form-core/src/pipeline/serverValidation.lib.ts b/packages/form-core/src/pipeline/serverValidation.lib.ts deleted file mode 100644 index 88cd40c2f..000000000 --- a/packages/form-core/src/pipeline/serverValidation.lib.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isErrorResult } from '../validation/errors.lib' -import { runPipeline } from './runPipeline.lib' -import { shouldRunValidator } from './utils.lib' -import { createFormServerValidatorContext } from './validationContext.lib' -import { ValidatorExecutor } from './validatorExecutor.lib' -import type { ValidatorTrigger } from './utils.lib' -import type { ValidatorDefinition } from './validatorExecutor.lib' -import type { PipelineEntry, PipelineResult, PipelineRun } from './state.lib' - -interface ServerValidator extends ValidatorDefinition { - triggers?: ReadonlyArray - bailIfInvalid?: boolean -} - -interface ServerValidationInput { - validators?: ReadonlyArray | null - value: any - onDeferredResults?: ( - results: Array>, - ) => void -} - -export function runServerValidators({ - validators, - value, - onDeferredResults, -}: ServerValidationInput): PipelineRun { - const entries: Array> = [] - - const list = validators ?? [] - - for (let i = 0; i < list.length; i++) { - const validator = list[i]! - - if (!shouldRunValidator(validator, { event: 'server' })) continue - - entries.push({ - instance: new ValidatorExecutor({ - definition: validator, - validatorScope: 'form', - index: i, - }), - bailIfInvalid: validator.bailIfInvalid, - }) - } - - return runPipeline({ - entries, - input: { - createContext: (definition) => - createFormServerValidatorContext(definition, { value }), - }, - isInvalid: ({ result }) => isErrorResult(result), - onDeferredResults, - }) -} diff --git a/packages/form-core/src/pipeline/state.lib.ts b/packages/form-core/src/pipeline/state.lib.ts index 31f8af819..67b910527 100644 --- a/packages/form-core/src/pipeline/state.lib.ts +++ b/packages/form-core/src/pipeline/state.lib.ts @@ -1,11 +1,6 @@ -import type { BaseInstance } from './baseInstance.lib' +import type { PipelineInstance } from './pipelineInstance.lib' import type { Awaitable } from '../types.public' -export type PipelineInstance = BaseInstance< - TInput, - TResult -> - type InputOf = TInstance extends PipelineInstance ? TInput : never @@ -32,6 +27,8 @@ export interface PipelineEntry { debounceMs?: number /** Wait for earlier tasks, then skip this entry if the run is invalid. */ bailIfInvalid?: boolean + /** Preparation errored. settle at this position without starting a task. */ + preparationFailure?: { error: unknown } } export interface PipelineResult { @@ -42,6 +39,22 @@ export interface PipelineResult { } export interface PipelineOptions { + /** Whether tasks replace the instance's current execution. + * @default true + */ + supersede?: boolean + /** + * - It was cancelled + * - A new task superceded it + * + * this does NOT include failure cleanup. + */ + onCancel?: () => void + /** Provides run and instance cancellation before dependencies or entries execute. */ + onStart?: ( + cancel: () => void, + cancelInstances: (instances: ReadonlySet) => void, + ) => void /** Input passed to every instance when its task executes. */ input: PipelineInput /** Ordered entries to process, including their debounce and bail settings. */ @@ -65,12 +78,15 @@ export interface PipelineRun { /** Results collected before runPipeline returns; later results use onDeferredResults. */ syncResults: Array> /** - * Deferred run lifetime, or `null` for synchronous runs. Cancellation resolves - * it; failure rejects it. + * Pending work for deferred stuff or failures if present. Otherwise `null`. */ pending: Promise | null + /** Whether work is unfinished when the starter returns, independent of failure. */ + hasPendingWork: boolean /** Cancels this run and suppresses results from its unfinished tasks. */ cancel: () => void + /** Cancels only these instances' queued and unfinished tasks in this run. */ + cancelInstances: (instances: ReadonlySet) => void } export interface PipelineState< @@ -78,8 +94,9 @@ export interface PipelineState< > extends PipelineOptions { /** Initial synchronous batch; resumed loops publish separate batches. */ syncResults: Array> - /** Handles that cancel this run's unfinished or debounced tasks. */ - pendingTasks: Set<() => void> + /** This run's unfinished or debounced tasks. */ + pendingTasks: Set> + cancelledInstances: Set | null /** Index of the next entry, held in place while a bail check waits. */ nextEntryIndex: number /** Whether this run may still start tasks or accept results. */ @@ -88,9 +105,11 @@ export interface PipelineState< processingEntries: boolean /** True until an upstream promise settles; separate from local tasks. */ hasPendingUpstream: boolean - /** True after upstream or an accepted result is invalid; stays true. */ + /** True after upstream invalidity, an invalid result, or occurrence failure. */ hasInvalidResult: boolean - /** Wrapper promise exposed by the run handle; `null` until work is deferred. */ + /** Original exceptions from active occurrences, without successful results. */ + errors: Array + /** Completion promise, created when work is deferred or an occurrence fails. */ pending: Promise | null /** Resolves the wrapper promise; `undefined` until that promise is created. */ resolvePending: (() => void) | undefined @@ -123,12 +142,14 @@ export function createPipelineState( return { ...options, syncResults: [], - pendingTasks: new Set<() => void>(), + pendingTasks: new Set>(), + cancelledInstances: null, nextEntryIndex: 0, active: true, processingEntries: false, hasPendingUpstream: false, hasInvalidResult: options.upstreamInvalid === true, + errors: [], pending: null, resolvePending: undefined, rejectPending: undefined, diff --git a/packages/form-core/src/pipeline/utils.lib.ts b/packages/form-core/src/pipeline/utils.lib.ts index 507690827..1716d70cd 100644 --- a/packages/form-core/src/pipeline/utils.lib.ts +++ b/packages/form-core/src/pipeline/utils.lib.ts @@ -1,82 +1,5 @@ import { isPromiseLike } from '../utils.lib' -import { isErrorResult } from '../validation/errors.lib' import type { Awaitable } from '../types.public' -import type { ValidatorEvents, ValidatorTriggers } from '../events.public' -import type { PipelineResult } from './state.lib' -import type { ValidatorExecutor } from './validatorExecutor.lib' - -// Both submit and validators have these, but it'd be silly to invent a -// class they inherit from instead of just asserting this. -interface ValidationSourceOrder { - readonly scope: number - readonly index: number -} - -/** Returns a copy ordered by numeric scope priority, then original index. */ -export function sortValidationSources( - sources: Iterable, -): Array { - return Array.from(sources).sort((left, right) => { - if (left.scope === right.scope) { - return left.index - right.index - } - return left.scope - right.scope - }) -} - -type ValidatorCondition = boolean | ((context: any) => boolean) - -export type ValidatorTrigger = - ValidatorTriggers | { trigger: ValidatorTriggers; when?: ValidatorCondition } - -interface ValidatorSelectionContext { - event: ValidatorEvents - createPredicateContext?: () => any -} - -export interface SelectableValidator { - triggers?: ReadonlyArray - skipOnSubmit?: ValidatorCondition -} - -function isConditionEnabled( - condition: ValidatorCondition, - context: ValidatorSelectionContext, -): boolean { - if (typeof condition === 'boolean') return condition - if (context.event === 'server' || !context.createPredicateContext) { - return false - } - - return condition(context.createPredicateContext()) -} - -export function isValidationTriggerEnabled( - trigger: ValidatorTrigger, - context: ValidatorSelectionContext, -): boolean { - if (typeof trigger === 'string') return trigger === context.event - if (trigger.trigger !== context.event) return false - - const { when = true } = trigger - return isConditionEnabled(when, context) -} - -export function shouldRunValidator( - validator: SelectableValidator, - context: ValidatorSelectionContext, -): boolean { - if (context.event === 'submit') { - const { skipOnSubmit = false } = validator - return !isConditionEnabled(skipOnSubmit, context) - } - - return ( - validator.triggers?.some((trigger) => - isValidationTriggerEnabled(trigger, context), - ) ?? false - ) -} /** * Normalizes a direct value immediately, or a promised value when it resolves. @@ -113,48 +36,3 @@ export function sleep( signal?.addEventListener('abort', finish, { once: true }) }) } - -/** Collects one run's accepted results in original validator-list order. */ -export class ValidationResultCollector< - TInstance extends ValidatorExecutor = ValidatorExecutor, -> { - private readonly accepted = new Map>() - - collect = (results: ReadonlyArray>): void => { - for (const result of results) { - this.accepted.set(result.instance, result) - } - } - - getResults = (): Array> => { - return Array.from(this.accepted.values()).sort( - (left, right) => left.instance.index - right.instance.index, - ) - } - - /** Checks whether this run's currently accepted results contain validation errors. */ - hasErrors = (): boolean => { - for (const { value } of this.accepted.values()) { - if (isErrorResult(value.result)) return true - } - - return false - } - - /** - * Returns outputs at their original indexes, leaving missing outputs `undefined`. - * - * @param validatorCount - Full validator-list length, including skipped validators. - */ - getOutputs = (validatorCount: number): Array => { - const outputs = new Array(validatorCount).fill(undefined) - - for (const { instance, value } of this.accepted.values()) { - if (value.hasOutput) { - outputs[instance.index] = value.output - } - } - - return outputs - } -} diff --git a/packages/form-core/src/pipeline/validation/ValidatorInstance.lib.ts b/packages/form-core/src/pipeline/validation/ValidatorInstance.lib.ts new file mode 100644 index 000000000..b08ff5162 --- /dev/null +++ b/packages/form-core/src/pipeline/validation/ValidatorInstance.lib.ts @@ -0,0 +1,285 @@ +import { mapResult } from '../utils.lib' +import { + VALIDATION_OUTPUT, + isValidationOutput, +} from '../../validation/results/validationOutput.lib' +import { PipelineInstance } from '../pipelineInstance.lib' +import { + removeSourceErrors, + validationSourceScopes, +} from '../../validation/errorStorage.lib' +import { isStandardSchema, parseStandardSchema } from './standardSchema.lib' +import type { StandardSchemaV1 } from '../../validation/results/standardSchema.public' +import type { Awaitable } from '../../types.public' +import type { + FieldErrorTarget, + FormValidationResult, +} from '../../validation/results/results.public' +import type { + FieldValidator, + FormGroupValidator, + ValidatorScope, +} from './validation.public' +import type { FormValidator } from './formValidators.public' +import type { + ValidationErrorSourceState, + ValidationErrorTarget, +} from '../../validation/errorStorage.lib' +import type { ValidatorEvents } from '../events.public' + +export interface ValidatorDefinition { + run: + StandardSchemaV1 | ((context: any) => Awaitable>) + fieldErrorTarget?: FieldErrorTarget +} + +export interface ValidatorExecutionResult { + result: FormValidationResult + output: unknown + hasOutput: boolean +} + +function executeDefinition( + definition: ValidatorDefinition, + context: { value: any }, + { scope }: { scope: ValidatorScope }, +): Awaitable { + let result + if (isStandardSchema(definition.run)) { + result = parseStandardSchema(definition.run, context.value, { + scope, + fieldErrorTarget: definition.fieldErrorTarget, + }) + } else { + result = definition.run(context) + } + + return mapResult(result, normalizeValidatorResult) +} + +function normalizeValidatorResult( + result: FormValidationResult, +): ValidatorExecutionResult { + if (isValidationOutput(result)) { + return { + result: null, + output: result[VALIDATION_OUTPUT], + hasOutput: true, + } + } + + return { result, output: null, hasOutput: false } +} + +interface ValidatorRunInput { + createContext: ( + definition: ValidatorDefinition, + signal: AbortSignal, + ) => { value: any } +} + +export class ValidatorExecutor< + TDefinition extends ValidatorDefinition = ValidatorDefinition, +> extends PipelineInstance { + definition: TDefinition + readonly validatorScope: ValidatorScope + readonly index: number + + constructor({ + definition, + validatorScope, + index, + }: { + definition: TDefinition + validatorScope: ValidatorScope + index: number + }) { + super() + this.definition = definition + this.validatorScope = validatorScope + this.index = index + } + + execute( + input: ValidatorRunInput, + signal: AbortSignal, + ): Awaitable { + const definition = this.definition + const context = input.createContext(definition, signal) + return executeDefinition(definition, context, { + scope: this.validatorScope, + }) + } +} + +type GetDefinition = { + form: FormValidator + group: FormGroupValidator + field: FieldValidator +}[TScope] + +export type AnyValidatorInstance = ValidatorInstance + +export interface ValidatorInstanceOptions { + owner: unknown + scope: TScope + definition: GetDefinition + index?: number +} + +/** Stable runtime instances correlated with validator definitions by slot. */ +export type ValidatorInstances = Array< + ValidatorInstance +> | null + +export interface UpdateValidatorInstancesOptions< + TScope extends ValidatorScope, +> { + /** 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: ValidatorInstances + /** The validation boundary that owns every updated instance. */ + owner: unknown + /** The form, group, or field scope shared by the updated instances. */ + scope: TScope + /** Cleans owner-specific state before an instance is permanently disposed. */ + onBeforeDispose?: (instance: ValidatorInstance) => void +} + +/** + * Runtime state owned by one installed validator occurrence. + */ +export class ValidatorInstance + extends ValidatorExecutor> + implements ValidationErrorSourceState +{ + /** + * Updates installed validator instances by slot. + * + * Retained slots preserve their instance and receive the latest definition. + * The definition count must remain constant after initialization. + * Missing and empty definition collections are normalized to `null`. + * Updates with a different definition count emit the shared invariant warning. + */ + static update({ + definitions, + previousDefinitions, + instances, + owner, + scope, + onBeforeDispose, + }: UpdateValidatorInstancesOptions): ValidatorInstances { + if ( + previousDefinitions !== undefined && + (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) + ) { + console.warn( + 'TanStack Form: The length of the validator array should not change after initialization', + ) + } + + if (!definitions || definitions.length === 0) { + instances?.forEach((instance) => { + instance.dispose(onBeforeDispose) + }) + return null + } + + const nextInstances = instances ?? [] + + definitions.forEach((definition, index) => { + const instance = nextInstances[index] + + if (instance) { + instance.updateDefinition(definition) + } else { + nextInstances[index] = new ValidatorInstance({ + definition, + owner, + scope, + index, + }) + } + }) + + return nextInstances + } + + /** The validation boundary that owns this source. */ + readonly owner: unknown + /** Numeric priority used to order errors across validation scopes. */ + readonly scope: (typeof validationSourceScopes)[TScope] + /** Targets currently receiving errors routed from this source. */ + errorTargets: Set | null = null + lastAppliedEvent: ValidatorEvents | null = null + /** Whether this source has been permanently disposed. */ + disposed = false + /** Creates the runtime state for one installed validator occurrence. */ + constructor({ + definition, + owner, + scope, + index = 0, + }: ValidatorInstanceOptions) { + super({ definition, validatorScope: scope, index }) + this.owner = owner + this.scope = validationSourceScopes[scope] + } + + /** + * Replaces the definition while preserving this instance and its runtime state. + * + * The operation is ignored after disposal. + */ + updateDefinition(definition: GetDefinition): void { + if (this.disposed) return + + this.definition = definition + } + + /** + * Clears transient execution and error-target state. + * + * The definition, owner, and scope are preserved. + * The operation is ignored after disposal. + */ + resetRuntime(): void { + if (this.disposed) return + + this._resetRuntime() + removeSourceErrors(this) + } + + /** Permanently disposes this source and releases its transient state. */ + dispose(onBeforeDispose?: (instance: this) => void): void { + if (this.disposed) return + + onBeforeDispose?.(this) + this._disposeRuntime() + removeSourceErrors(this) + this.disposed = true + } + + /** Releases validator-specific runtime state during reset or disposal. */ + protected _resetRuntime(): void { + this._cancelExecution() + } + + /** Releases validator-only collections and mount state during disposal. */ + protected _disposeRuntime(): void { + this._resetRuntime() + } + + /** Cancels and releases execution resources without checking disposal state. */ + private _cancelExecution(): void { + this.cancelCurrentExecution?.() + } +} diff --git a/packages/form-core/src/pipeline/validation/clientValidation.lib.ts b/packages/form-core/src/pipeline/validation/clientValidation.lib.ts new file mode 100644 index 000000000..5c78f607b --- /dev/null +++ b/packages/form-core/src/pipeline/validation/clientValidation.lib.ts @@ -0,0 +1,221 @@ +import { batch } from '@tanstack/store' +import { watchesField } from '../../FieldApi/linked-fields.lib' +import { resolveDebounceMs } from '../selection.lib' +import { runPipeline } from '../runPipeline.lib' +import { isErrorResult } from '../../validation/results/errors.lib' +import { ValidationResultCollector } from './resultCollection.lib' +import { selectValidator } from './selection.lib' +import type { + AnyValidatorInstance, + ValidatorDefinition, +} from './ValidatorInstance.lib' +import type { + PipelineEntry, + PipelineInput, + PipelineResult, + PipelineRun, +} from '../state.lib' +import type { ValidationRunTracker } from '../runTracker.lib' +import type { ClientSideEvents } from '../events.public' +import type { Awaitable } from '../../types.public' +import type { SubmissionAttempt } from '../../submissionAttempt.lib' + +export interface ClientValidationRun< + TInstance extends AnyValidatorInstance, +> extends PipelineRun { + collector: ValidationResultCollector + validatorCount: number +} + +export interface ClientValidationOptions< + TInstance extends AnyValidatorInstance, +> { + /** Log startup and completion failures without replacing the completion promise. */ + catchErrors: boolean + tracker: ValidationRunTracker | null + validators: ReadonlyArray | null + event: ClientSideEvents | 'mount' + watchedFieldName?: string + upstreamInvalid?: Awaitable + submission?: SubmissionAttempt + createPredicateContext: () => any + createContext: ( + definition: ValidatorDefinition, + signal: AbortSignal, + ) => { value: any } + onSelected?: (selected: ReadonlySet) => void + onSkip?: (instance: TInstance) => void + applyResult?: (result: PipelineResult) => void + onPending: () => () => void + onRegister?: (run: ClientValidationRun) => () => void +} + +export function startClientValidation( + options: ClientValidationOptions, +): ClientValidationRun | undefined { + const { event } = options + const lifetime = { cancelled: false, finished: false } + + let cancelPipeline: (() => void) | undefined + let run!: ClientValidationRun + let release: (() => void) | undefined + let finishContribution: (() => void) | undefined + let releaseExternal: (() => void) | undefined + + const collector = new ValidationResultCollector() + const finish = () => { + if (lifetime.finished) return + lifetime.finished = true + release?.() + releaseExternal?.() + finishContribution?.() + } + + const accept = (results: Array>) => { + collector.collect(results) + if ( + !lifetime.cancelled && + options.submission?.canApplyValidationResults() !== false && + options.applyResult + ) { + batch(() => { + // Application can synchronously change values or reset the owner. + for (const result of results) { + if (lifetime.cancelled) break + options.applyResult!(result) + } + }) + } + } + + try { + const validators = options.validators?.slice() ?? null + const entries: Array> = [] + const disabled = new Set() + + for (const instance of validators ?? []) { + if ( + options.watchedFieldName !== undefined && + !watchesField(instance.definition.watchFields, options.watchedFieldName) + ) { + continue + } + + const definition = instance.definition + const entry: PipelineEntry = { + instance: instance as PipelineEntry['instance'], + bailIfInvalid: definition.bailIfInvalid, + } + + try { + const createPredicateContext = + event === 'mount' ? undefined : options.createPredicateContext + + const selection = selectValidator(definition, { + event, + createPredicateContext, + }) + + if (selection === 'cancel') disabled.add(instance) + if (selection !== 'run') continue + + entry.debounceMs = resolveDebounceMs( + definition, + event, + options.createPredicateContext, + ) + } catch (error) { + entry.preparationFailure = { error } + } + // Failed preparations also retain their previous source errors. + entries.push(entry) + } + + const pipeline = runPipeline({ + entries, + supersede: !options.submission, + input: { + createContext: options.createContext, + } as PipelineInput, + upstreamInvalid: options.upstreamInvalid, + onCancel: options.submission ? () => run.cancel() : undefined, + onStart: (cancel, cancelInstances) => { + // Note: This nesting is a bit silly. We should refactor sometime. + cancelPipeline = cancel + run = { + syncResults: [], + pending: null, + hasPendingWork: false, + collector, + validatorCount: validators?.length ?? 0, + cancel: () => { + if (lifetime.cancelled || lifetime.finished) return + lifetime.cancelled = true + options.submission?.cancel() + cancel() + finish() + }, + cancelInstances, + } + // Bind cancellation before registration or publication can reenter. + release = options.submission + ? options.submission.registerRun(run) + : options.tracker?.register(run) + + if (lifetime.finished) release?.() + + releaseExternal = options.submission + ? undefined + : options.onRegister?.(run) + + if (lifetime.finished) releaseExternal?.() + + if (!options.submission && !lifetime.cancelled && disabled.size > 0) { + options.tracker?.cancelInstances(disabled) + } + if (!lifetime.cancelled) { + const selected = new Set(entries.map(({ instance }) => instance)) + options.onSelected?.(selected) + } + }, + isInvalid: ({ result }) => isErrorResult(result), + onDeferredResults: accept, + onSkip: options.onSkip, + }) + run.syncResults = pipeline.syncResults + run.pending = pipeline.pending + run.hasPendingWork = pipeline.hasPendingWork + if (run.pending) { + // Observe failures before state publication or initial result application. + void run.pending.then(finish, finish) + if (run.hasPendingWork && !lifetime.cancelled) { + // Install cleanup before subscribers can reset or replace this run. + batch(() => { + finishContribution = options.onPending() + if (lifetime.finished) finishContribution() + }) + } + } + accept(run.syncResults) + if (!run.hasPendingWork) finish() + if (options.catchErrors) { + void run.pending?.catch((error) => console.error(error)) + } + return run + } catch (error) { + // Failure teardown must not turn an active submission into cancellation. + let failure = error + try { + lifetime.cancelled = true + cancelPipeline?.() + finish() + } catch (cleanupError) { + failure = cleanupError + } + if (options.catchErrors) { + console.error(failure) + return undefined + } + throw failure + } +} diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/pipeline/validation/formValidators.public.ts similarity index 98% rename from packages/form-core/src/validation/formValidators.public.ts rename to packages/form-core/src/pipeline/validation/formValidators.public.ts index 909f05e4a..902271799 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/pipeline/validation/formValidators.public.ts @@ -1,4 +1,9 @@ -import type { AnyFieldApi } from '../FieldApi/FieldApi.public' +import type { + CreateErrorMapFn, + CreateOutputFn, + FormValidationResult, + ParseIssuesFn, +} from '../../validation/results/results.public' import type { ClientSideEvents, DynamicValidatorTriggers, @@ -8,15 +13,10 @@ import type { ValidatorEvents, ValidatorTriggers, } from '../events.public' -import type { StandardSchemaV1 } from '../standardSchema.public' -import type { - CreateErrorMapFn, - CreateOutputFn, - FormValidationResult, - ParseIssuesFn, -} from './state.public' -import type { FormApi } from '../FormApi/FormApi.public' -import type { Awaitable } from '../types.public' +import type { FormApi } from '../../FormApi/FormApi.public' +import type { AnyFieldApi } from '../../FieldApi/FieldApi.public' +import type { Awaitable } from '../../types.public' +import type { StandardSchemaV1 } from '../../validation/results/standardSchema.public' // =================================== // `run: () => {}` Context data diff --git a/packages/form-core/src/pipeline/validation/ownerValidation.lib.ts b/packages/form-core/src/pipeline/validation/ownerValidation.lib.ts new file mode 100644 index 000000000..ba2752709 --- /dev/null +++ b/packages/form-core/src/pipeline/validation/ownerValidation.lib.ts @@ -0,0 +1,347 @@ +import { batch } from '@tanstack/store' +import { + createInitialFormState, + getInitialFormValues, +} from '../../FormApi/initialFormState.lib' +import { validationSourceScopes } from '../../validation/errorStorage.lib' +import { startClientValidation } from './clientValidation.lib' +import { + createFieldValidatorContext, + createFormClientValidatorContext, + createFormMountValidatorContext, + createGroupValidatorContext, +} from './validationContext.lib' +import type { + ClientValidationOptions, + ClientValidationRun, +} from './clientValidation.lib' +import type { ClientValidationTrigger } from './validation.public' +import type { + AnyInternalFieldApi, + InternalFieldValidatorInstance, +} from '../../FieldApi/FieldApi.lib' +import type { + AnyInternalFormApi, + InternalFormApi, + InternalFormValidatorInstance, +} from '../../FormApi/FormApi.lib' +import type { ServerFormState } from '../../FormApi/formServerState.public' +import type { + AnyInternalFormGroupApi, + InternalGroupValidatorInstance, +} from '../../FormGroupApi/FormGroupApi.lib' +import type { SubmissionAttempt } from '../../submissionAttempt.lib' +import type { FieldApiOverrideOptions } from '../../types.lib' +import type { Awaitable } from '../../types.public' +import type { ClientSideEvents } from '../events.public' +import type { ValidationRunTracker } from '../runTracker.lib' + +export interface FieldValidationOptions { + catchErrors: boolean + onResult?: boolean + submission?: SubmissionAttempt + watchedFieldName?: string + onRegister?: ( + run: ClientValidationRun, + ) => () => void + _invalidateOnChange?: boolean +} + +export interface FormValidationOptions extends FieldApiOverrideOptions { + catchErrors: boolean + onResult?: boolean + hasFailedBefore?: boolean + submission?: SubmissionAttempt +} + +interface FormMountValidationOptions { + defaultValues: TFormData + serverState?: ServerFormState> | null + validators: ReadonlyArray | null + tracker: ValidationRunTracker +} + +interface FormMountValidation { + initialState: ReturnType> + complete: (form: InternalFormApi) => void + cancel: () => void +} + +export interface GroupValidationOptions { + catchErrors: boolean + triggerFieldApi?: AnyInternalFieldApi + upstreamInvalid?: Awaitable + submission?: SubmissionAttempt +} + +export function startFieldValidation( + field: AnyInternalFieldApi, + tracker: ValidationRunTracker | null, + event: 'mount' | 'change' | 'blur' | 'submit', + options: FieldValidationOptions, +): ClientValidationRun | undefined { + const applyResults = options.onResult !== false + const snapshot = options.submission?.fields.get(field) + + let validators: InternalFieldValidatorInstance[] | null = null + if (snapshot) { + validators = snapshot.validators + } else if (!field._isKilled) { + validators = field._validatorInstances + } + + const invalidate = + applyResults && event === 'change' && options._invalidateOnChange + + const runOptions: ClientValidationOptions = { + catchErrors: options.catchErrors, + tracker, + validators, + event, + watchedFieldName: options.watchedFieldName, + onRegister: options.onRegister, + submission: options.submission, + createPredicateContext: () => ({ + scope: 'field', + fieldApi: field, + formApi: field.form, + value: snapshot ? snapshot.value : field.value, + }), + createContext: (definition, signal) => + createFieldValidatorContext(definition, { + event, + signal, + value: snapshot ? snapshot.value : field.value, + fieldApi: field, + formApi: field.form, + }), + onPending: () => { + field._setValidationCount((count) => count + 1) + return () => field._setValidationCount((count) => Math.max(0, count - 1)) + }, + } + + if (invalidate) { + runOptions.onSelected = (selected) => { + return field._clearStaleErrors((instance) => !selected.has(instance)) + } + runOptions.onSkip = (skipped) => { + return field._clearStaleErrors((instance) => instance === skipped) + } + } + + if (applyResults) { + runOptions.applyResult = (result) => { + return field._processValidationResult(result, event) + } + } + + return startClientValidation(runOptions) +} + +export function startFormMountValidation( + options: FormMountValidationOptions, +): FormMountValidation { + const value = getInitialFormValues(options.defaultValues, options.serverState) + let form: InternalFormApi | undefined + let hasPendingMount = false + let run: ClientValidationRun | undefined + if (options.validators) { + run = startClientValidation({ + catchErrors: true, + tracker: options.tracker, + validators: options.validators, + event: 'mount', + createPredicateContext: () => ({ value }), + createContext: (definition) => + createFormMountValidatorContext(definition, { value }), + applyResult: (result) => { + // Initial results are merged before any field targets exist. + form?._applyValidationResult( + result.instance, + result.value.result, + 'mount', + ) + }, + onPending: () => { + hasPendingMount = true + return () => { + hasPendingMount = false + form?._setValidationCount((count) => Math.max(0, count - 1)) + } + }, + }) + } + + try { + const initialState = createInitialFormState({ + values: value, + defaultValues: options.defaultValues, + serverState: options.serverState, + mountResults: run?.syncResults, + hasPendingMount, + }) + + return { + initialState, + complete: (initializedForm) => { + form = initializedForm + try { + batch(() => { + for (const result of initialState.validationResults) { + initializedForm._applyValidationResult( + result.validatorIndex, + result.result, + result.sourceEvent, + ) + } + }) + } catch (error) { + run?.cancel() + throw error + } + }, + cancel: () => run?.cancel(), + } + } catch (error) { + run?.cancel() + throw error + } +} + +export function startFormValidation( + form: AnyInternalFormApi, + tracker: ValidationRunTracker, + event: ClientSideEvents, + options: FormValidationOptions, +): ClientValidationRun | undefined { + const applyResults = options.onResult !== false + const invalidate = event === 'change' && applyResults + const runOptions: ClientValidationOptions = { + catchErrors: options.catchErrors, + tracker, + validators: options.submission + ? (options.submission + .validators as Array | null) + : form._validatorInstances, + event, + upstreamInvalid: options.hasFailedBefore, + submission: options.submission, + createPredicateContext: () => ({ + scope: 'form', + formApi: form, + fieldApi: options.fieldApiOverride ?? undefined, + value: options.submission + ? options.submission.value + : form._atoms.values.get(), + }), + createContext: (definition, signal) => + createFormClientValidatorContext(definition, { + event, + value: options.submission + ? options.submission.value + : form._atoms.values.get(), + signal, + formApi: form, + triggerFieldApi: options.fieldApiOverride ?? undefined, + }), + onPending: () => { + form._setValidationCount((count) => count + 1) + return () => form._setValidationCount((count) => Math.max(0, count - 1)) + }, + } + + if (invalidate) { + runOptions.onSelected = (selected) => { + form._clearStaleErrors( + options.fieldApiOverride ?? null, + true, + (source) => { + return ( + source.scope === validationSourceScopes.form && + !selected.has(source) + ) + }, + ) + } + runOptions.onSkip = (instance) => + form._clearStaleErrors( + options.fieldApiOverride ?? null, + true, + (source) => source === instance, + ) + } + + if (applyResults) { + runOptions.applyResult = (result) => + form._applyValidationResult(result.instance, result.value.result, event) + } + + return startClientValidation(runOptions) +} + +export function startGroupValidation( + group: AnyInternalFormGroupApi, + tracker: ValidationRunTracker, + event: ClientValidationTrigger | 'mount', + options: GroupValidationOptions, +): ClientValidationRun | undefined { + const groupField = group._groupField + const invalidate = event === 'change' + const runOptions: ClientValidationOptions = { + catchErrors: options.catchErrors, + tracker, + validators: options.submission + ? (options.submission + .validators as Array | null) + : group._validatorInstances, + event, + upstreamInvalid: options.upstreamInvalid, + submission: options.submission, + createPredicateContext: () => ({ + scope: 'group', + formApi: group.form, + fieldApi: options.triggerFieldApi, + groupApi: group, + value: options.submission ? options.submission.value : group.value, + }), + createContext: (definition, signal) => + createGroupValidatorContext(definition, { + event, + formApi: group.form, + groupApi: group, + triggerFieldApi: options.triggerFieldApi, + value: options.submission ? options.submission.value : group.value, + signal, + }), + applyResult: (result) => group._processValidationResult(result, event), + onPending: () => { + groupField._setValidationCount((count) => count + 1) + return () => + groupField._setValidationCount((count) => Math.max(0, count - 1)) + }, + } + + if (invalidate) { + runOptions.onSelected = (selected) => + group.form._clearStaleErrors( + options.triggerFieldApi ?? groupField, + true, + (source) => + source.scope === validationSourceScopes.group && + source.owner === group && + !selected.has(source), + ) + + if (options.triggerFieldApi) { + runOptions.onSkip = (instance) => + group.form._clearStaleErrors( + options.triggerFieldApi!, + true, + (source) => source === instance, + ) + } + } + + return startClientValidation(runOptions) +} diff --git a/packages/form-core/src/pipeline/validation/resultCollection.lib.ts b/packages/form-core/src/pipeline/validation/resultCollection.lib.ts new file mode 100644 index 000000000..b155bbfb3 --- /dev/null +++ b/packages/form-core/src/pipeline/validation/resultCollection.lib.ts @@ -0,0 +1,48 @@ +import { isErrorResult } from '../../validation/results/errors.lib' +import type { ValidatorExecutor } from './ValidatorInstance.lib' +import type { PipelineResult } from '../state.lib' + +/** Collects one run's accepted results in original validator-list order. */ +export class ValidationResultCollector< + TInstance extends ValidatorExecutor = ValidatorExecutor, +> { + private readonly accepted = new Map>() + + collect = (results: ReadonlyArray>): void => { + for (const result of results) { + this.accepted.set(result.instance, result) + } + } + + getResults = (): Array> => { + return Array.from(this.accepted.values()).sort( + (left, right) => left.instance.index - right.instance.index, + ) + } + + /** Checks whether this run's currently accepted results contain validation errors. */ + hasErrors = (): boolean => { + for (const { value } of this.accepted.values()) { + if (isErrorResult(value.result)) return true + } + + return false + } + + /** + * Returns outputs at their original indexes, leaving missing outputs `undefined`. + * + * @param validatorCount - Full validator-list length, including skipped validators. + */ + getOutputs = (validatorCount: number): Array => { + const outputs = new Array(validatorCount).fill(undefined) + + for (const { instance, value } of this.accepted.values()) { + if (value.hasOutput) { + outputs[instance.index] = value.output + } + } + + return outputs + } +} diff --git a/packages/form-core/src/pipeline/validation/selection.lib.ts b/packages/form-core/src/pipeline/validation/selection.lib.ts new file mode 100644 index 000000000..c037dd38b --- /dev/null +++ b/packages/form-core/src/pipeline/validation/selection.lib.ts @@ -0,0 +1,46 @@ +import { isConditionMet, selectTriggers } from '../selection.lib' +import type { + PipelineCondition, + PipelineSelection, + PipelineTrigger, +} from '../selection.lib' +import type { ValidatorEvents, ValidatorTriggers } from '../events.public' + +type ValidatorCondition = PipelineCondition + +export type ValidatorTrigger = PipelineTrigger + +interface ValidatorSelectionContext { + event: ValidatorEvents + createPredicateContext?: () => any +} + +export interface SelectableValidator { + triggers?: ReadonlyArray + skipOnSubmit?: ValidatorCondition +} + +export function shouldRunValidator( + validator: SelectableValidator, + context: ValidatorSelectionContext, +): boolean { + return selectValidator(validator, context) === 'run' +} + +export function selectValidator( + validator: SelectableValidator, + context: ValidatorSelectionContext, +): PipelineSelection { + const createPredicateContext = + context.event === 'server' ? undefined : context.createPredicateContext + if (context.event === 'submit') { + const { skipOnSubmit = false } = validator + return isConditionMet(skipOnSubmit, createPredicateContext) ? 'skip' : 'run' + } + + return selectTriggers( + validator.triggers, + context.event, + createPredicateContext, + ) +} diff --git a/packages/form-core/src/pipeline/validation/serverValidation.lib.ts b/packages/form-core/src/pipeline/validation/serverValidation.lib.ts new file mode 100644 index 000000000..964516448 --- /dev/null +++ b/packages/form-core/src/pipeline/validation/serverValidation.lib.ts @@ -0,0 +1,113 @@ +import { runPipeline } from '../runPipeline.lib' +import { + formatValidationResult, + isErrorResult, +} from '../../validation/results/errors.lib' +import { ValidatorExecutor } from './ValidatorInstance.lib' +import { shouldRunValidator } from './selection.lib' +import { createFormServerValidatorContext } from './validationContext.lib' +import { ValidationResultCollector } from './resultCollection.lib' +import type { ValidatorDefinition } from './ValidatorInstance.lib' +import type { ValidatorTrigger } from './selection.lib' +import type { PipelineEntry, PipelineResult, PipelineRun } from '../state.lib' +import type { FormOptions } from '../../FormApi/formOptions.public' +import type { ServerValidateResult } from '../../FormApi/formServerState.public' + +interface ServerValidator extends ValidatorDefinition { + triggers?: ReadonlyArray + bailIfInvalid?: boolean +} + +interface ServerValidationInput { + validators?: ReadonlyArray | null + value: any + onDeferredResults?: ( + results: Array>, + ) => void +} + +export function runServerValidators({ + validators, + value, + onDeferredResults, +}: ServerValidationInput): PipelineRun { + const entries: Array> = [] + + const list = validators ?? [] + + for (let i = 0; i < list.length; i++) { + const validator = list[i]! + + if (!shouldRunValidator(validator, { event: 'server' })) continue + + entries.push({ + instance: new ValidatorExecutor({ + definition: validator, + validatorScope: 'form', + index: i, + }), + bailIfInvalid: validator.bailIfInvalid, + }) + } + + return runPipeline({ + entries, + input: { + createContext: (definition) => + createFormServerValidatorContext(definition, { value }), + }, + isInvalid: ({ result }) => isErrorResult(result), + onDeferredResults, + }) +} + +// Yes, this is used, but in SSR adapters. Your IDE might not pick up on that. +export async function validateServerValues< + TFormData, + const TValidatorConfig extends unknown[], + TValidators, + TSubmitReturn, +>( + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, + values: TFormData, +): Promise> { + const validators = options.validators + const validatorCount = validators?.length ?? 0 + const collector = new ValidationResultCollector() + const run = runServerValidators({ + validators, + value: values, + onDeferredResults: collector.collect, + }) + collector.collect(run.syncResults) + await run.pending + + if (collector.hasErrors()) { + return { + success: false, + serverState: { + values, + validationResults: collector + .getResults() + .map(({ instance, value }) => ({ + validatorIndex: instance.index, + ...value, + result: formatValidationResult(value.result), + })), + submissionAttempts: 1, + }, + } + } + + return { + success: true, + values, + validatorOutputs: collector.getOutputs(validatorCount) as any, + } +} diff --git a/packages/form-core/src/pipeline/standardSchema.lib.ts b/packages/form-core/src/pipeline/validation/standardSchema.lib.ts similarity index 66% rename from packages/form-core/src/pipeline/standardSchema.lib.ts rename to packages/form-core/src/pipeline/validation/standardSchema.lib.ts index cd130571d..47008d6f9 100644 --- a/packages/form-core/src/pipeline/standardSchema.lib.ts +++ b/packages/form-core/src/pipeline/validation/standardSchema.lib.ts @@ -1,17 +1,17 @@ -import { createOutput } from '../validationOutput.lib' -import { parseIssuesFactory } from '../validation/parseIssues.lib' -import { mapResult } from './utils.lib' +import { mapResult } from '../utils.lib' +import { createOutput } from '../../validation/results/validationOutput.lib' +import { parseIssuesFactory } from '../../validation/results/parseIssues.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, -} from '../standardSchema.public' -import type { Awaitable } from '../types.public' -import type { ValidatorScope } from '../validation.public' +} from '../../validation/results/standardSchema.public' +import type { ValidatorScope } from './validation.public' import type { FieldErrorTarget, ParsedStandardSchemaIssues, ValidationOutput, -} from '../validation/state.public' +} from '../../validation/results/results.public' +import type { Awaitable } from '../../types.public' export function isStandardSchema( value: unknown, @@ -23,15 +23,15 @@ export function isStandardSchema( ) } -export function parseStandardSchema( - schema: StandardSchemaV1, +export function parseStandardSchema( + schema: StandardSchemaV1, value: unknown, { scope, fieldErrorTarget = 'field', }: { scope: ValidatorScope; fieldErrorTarget?: FieldErrorTarget }, ): Awaitable< - | ValidationOutput + | ValidationOutput | ParsedStandardSchemaIssues | ReadonlyArray > { diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/pipeline/validation/validation.public.ts similarity index 73% rename from packages/form-core/src/validation.public.ts rename to packages/form-core/src/pipeline/validation/validation.public.ts index 50dbf131b..225911c8b 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/pipeline/validation/validation.public.ts @@ -1,26 +1,19 @@ -import type { DeepKeys } from './deep-keys.public' -import type { FormApi } from './FormApi/FormApi.public' -import type { AnyFieldApi, FieldApi } from './FieldApi/FieldApi.public' -import type { FormGroupApi } from './FormGroupApi/FormGroupApi.public' -import type { - StandardSchemaV1, - StandardSchemaV1Issue, -} from './standardSchema.public' -import type { OneOrMany, ReadonlyOneOrMany } from './types.public' +import type { StandardSchemaV1 } from '../../validation/results/standardSchema.public' +import type { FormApi } from '../../FormApi/FormApi.public' +import type { AnyFieldApi, FieldApi } from '../../FieldApi/FieldApi.public' +import type { FormGroupApi } from '../../FormGroupApi/FormGroupApi.public' import type { CreateErrorMapFn, CreateOutputFn, + FieldValidateResult, + FormGroupValidateResult, ParseIssuesFn, - ScopedErrorTypes, + SuccessfulValidationResult, ValidationErrorMap, ValidationIssue, ValidationOutput, -} from './validation/state.public' -import type { OnSubmitError } from './FormApi/formSubmit.public' -import type { - ErrorVisibilityContext, - ErrorVisibilityFieldState, -} from './FormApi/errorVisibility.public' +} from '../../validation/results/results.public' +import type { DeepKeys } from '../../deep-keys.public' /** * A validation function or Standard Schema with an optional rule to stop after earlier failures. @@ -265,52 +258,6 @@ export type ValidatorTrigger = ConfigurableValidationTrigger /** The form, form group, or field whose value a validator checks. */ export type ValidatorScope = 'form' | 'group' | 'field' -/** - * Form or group state available to a reusable error visibility policy. - * - * `values` is `unknown` because the policy can be used with different form shapes. - */ -export type ReusableErrorVisibilityState = Omit< - FormApi['state'], - 'values' -> & { - /** Current form values, without assuming a particular form shape. */ - values: unknown -} - -/** State available to a visibility policy shared across different form shapes. */ -export interface ReusableErrorVisibilityContext { - /** Form state with group-scoped flags and counters for fields in a form group. */ - state: ReusableErrorVisibilityState - /** The field whose errors the policy controls. */ - fieldState: ErrorVisibilityFieldState -} - -/** - * An error visibility policy that accepts forms with different value and error types. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type ReusableErrorVisibility = ( - context: ErrorVisibilityContext, -) => boolean - -/** - * Creates an error visibility policy that can be shared across forms with different values. - * - * Use an inline `errorVisibility` callback instead when the policy needs - * strongly typed access to the consuming form's `values`. - * - * @param visibility - Returns `true` to show the field's errors, or `false` to hide them. - * @returns The policy to assign to a form or field's `errorVisibility` option. - */ -export function createErrorVisibility( - visibility: (context: ReusableErrorVisibilityContext) => boolean, -): ReusableErrorVisibility { - return visibility -} - interface BaseValidationPredicateContext< in out TFormData, out TValue, @@ -499,13 +446,6 @@ export type FormValidationTriggerOption< | ClientValidationTriggerOption | ServerValidationTrigger -/** An error supplied as an issue object or a string that becomes its `message`. */ -export type ValidationErrorValue = ValidationIssue | string -/** One or more issue objects after string errors have been normalized. */ -export type ValidationError = OneOrMany -/** One or more errors accepted from a validator, including string messages. */ -export type ValidationErrorInput = ReadonlyOneOrMany - /** * Values and helpers passed to a form validation function. * @@ -554,18 +494,6 @@ export interface ServerFormValidatorContext { createOutput: CreateOutputFn } -/** A validator return value that indicates success without providing output data. */ -export type ValidValidationResult = null | undefined | false - -/** A validator return value that indicates success, with or without output data. */ -export type SuccessfulValidationResult = - ValidValidationResult | ValidationOutput - -/** - * One or more validation errors, or `null`, `undefined`, or `false` for success. - */ -export type ValidationResult = ValidValidationResult | ValidationErrorInput - /** * A validation callback that can return a result immediately or asynchronously. * @@ -602,22 +530,6 @@ export interface FormGroupValidatorContext { createOutput: CreateOutputFn } -/** - * Errors for a form group, optionally including errors at group-relative field paths. - * - * @typeParam TGroupValue - Library-managed. Do not specify explicitly. - */ -export type FormGroupValidationError = - | ValidationErrorInput - | ValidationErrorMap -/** - * A form group validator's success result, output data, or validation errors. - * - * @typeParam TGroupValue - Library-managed. Do not specify explicitly. - */ -export type FormGroupValidateResult = - SuccessfulValidationResult | FormGroupValidationError - /** * Validates a group's value and can return group errors, field errors, or output data. * @@ -674,9 +586,6 @@ export interface FieldValidatorContext< value: TFieldValue } -/** A field validator's success result or errors. Field validators do not provide output data. */ -export type FieldValidateResult = ValidationResult - /** * Validates a field's value and reports errors owned by that field. * @@ -731,75 +640,6 @@ export type FieldValidators = ReadonlyArray< FieldValidator > -type NormalizeValidationResult = NormalizeValidationError< - Exclude -> - -type NormalizeValidationError = - TError extends ReadonlyArray - ? NormalizeValidationError - : TError extends string - ? ValidationIssue - : TError extends ValidationIssue - ? TError - : ValidationIssue - -type ValidationErrorTarget = 'form' | 'field' - -type ExtractErrorMap< - TResult, - TTarget extends ValidationErrorTarget, -> = TResult extends ValidationIssue - ? NormalizeValidationResult - : TResult extends ValidationErrorMap - ? TTarget extends 'form' - ? TResult extends { form?: infer TError } - ? NormalizeValidationResult - : never - : TResult extends { fields: infer TFields } - ? NormalizeValidationResult - : never - : NormalizeValidationResult - -type ExtractSubmitValidationError = - Awaited extends infer TResolved - ? TResolved extends OnSubmitError - ? TError - : never - : never - -type ParseSubmitFormError = ExtractErrorMap< - ExtractSubmitValidationError, - 'form' -> - -type ParseSubmitFieldError = ExtractErrorMap< - ExtractSubmitValidationError, - 'field' -> - -type ExtractSubmitFormError = unknown extends TSubmitReturn - ? ValidationIssue - : ParseSubmitFormError - -type ExtractSubmitFieldError = unknown extends TSubmitReturn - ? ValidationIssue - : ParseSubmitFieldError - -type ExtractFormFieldError = - unknown extends TFormError['subfieldError'] - ? never - : TFormError['subfieldError'] - -/** - * A field's error array, with string messages normalized to issue objects. - * - * @typeParam TFieldError - Library-managed. Do not specify explicitly. - */ -export type FieldErrors = Array< - unknown extends TFieldError ? ValidationIssue : TFieldError -> - // Scalars and branded returns share the runtime success classification. Error // collections additionally allow success when they contain no stored errors. type SuccessWithoutOutput = @@ -861,22 +701,6 @@ type TryGetSubmitOutput = : TOutput // default, which is guaranteed present : never -type TryGetFormError = TValidator extends { - readonly run: StandardSchemaV1 -} - ? StandardSchemaV1Issue - : TValidator extends { readonly run: (...args: any) => infer TReturn } - ? ExtractErrorMap, 'form'> - : never - -type TryGetFieldError = TValidator extends { - readonly run: StandardSchemaV1 -} - ? StandardSchemaV1Issue - : TValidator extends { readonly run: (...args: any) => infer TReturn } - ? ExtractErrorMap, 'field'> - : never - type MappedOutputs> = { [K in keyof TValidators]: TValidators[K] extends { readonly run: any @@ -906,78 +730,3 @@ type ToOutputs< export type ToFormGroupOutputs< TGroupValidators extends FormGroupValidators, > = ToOutputs> - -type ExtractValidatorFormError< - TValidators extends ReadonlyArray, - TBroadValidators extends ReadonlyArray, -> = unknown extends TValidators - ? never - : TBroadValidators extends TValidators - ? never - : TryGetFormError - -type ExtractValidatorFieldError< - TValidators extends ReadonlyArray, - TBroadValidators extends ReadonlyArray, -> = unknown extends TValidators - ? never - : TBroadValidators extends TValidators - ? never - : TryGetFieldError - -type GetSelfErrors< - TValidators extends ReadonlyArray, - TBroadValidators extends ReadonlyArray, - TSubmitReturn, -> = - | ExtractValidatorFormError - | ExtractSubmitFormError - -type GetSubfieldErrors< - TValidators extends ReadonlyArray, - TBroadValidators extends ReadonlyArray, - TSubmitReturn, -> = - | ExtractValidatorFieldError - | ExtractSubmitFieldError - -type ToValidatorErrorTypes< - TValidators extends ReadonlyArray, - TBroadValidators extends ReadonlyArray, - TSubmitReturn, -> = ScopedErrorTypes< - GetSelfErrors, - GetSubfieldErrors -> - -/** - * Infers group and routed field issue types from group validators. - * - * @typeParam TGroupValidators - Library-managed. Do not specify explicitly. - */ -export type ToFormGroupErrorTypes< - TGroupValidators extends FormGroupValidators, -> = ToValidatorErrorTypes, never> - -type FallbackToValidationIssue = [TFieldError] extends [never] - ? ValidationIssue - : TFieldError - -/** - * Combines a field's own issue types with errors routed from its group and form. - * - * Falls back to `ValidationIssue` when no specific error type is inferred. - * - * @typeParam TFieldValidators - Library-managed. Do not specify explicitly. - * @typeParam TGroupFieldError - Library-managed. Do not specify explicitly. - * @typeParam TFormError - Library-managed. Do not specify explicitly. - */ -export type ToFieldError< - TFieldValidators extends FieldValidators, - TGroupFieldError, - TFormError extends ScopedErrorTypes, -> = FallbackToValidationIssue< - | ExtractValidatorFieldError> - | (unknown extends TGroupFieldError ? never : TGroupFieldError) - | ExtractFormFieldError -> diff --git a/packages/form-core/src/pipeline/validationContext.lib.ts b/packages/form-core/src/pipeline/validation/validationContext.lib.ts similarity index 89% rename from packages/form-core/src/pipeline/validationContext.lib.ts rename to packages/form-core/src/pipeline/validation/validationContext.lib.ts index 616b457c5..29342a2d8 100644 --- a/packages/form-core/src/pipeline/validationContext.lib.ts +++ b/packages/form-core/src/pipeline/validation/validationContext.lib.ts @@ -1,17 +1,17 @@ -import { createOutput } from '../validationOutput.lib' -import { createErrorMap } from '../validation/createErrorMap.lib' -import { parseIssuesFactory } from '../validation/parseIssues.lib' +import { createErrorMap } from '../../validation/results/createErrorMap.lib' +import { parseIssuesFactory } from '../../validation/results/parseIssues.lib' +import { createOutput } from '../../validation/results/validationOutput.lib' import type { ClientSideEvents } from '../events.public' -import type { - FieldValidatorContext, - FormGroupValidatorContext, -} from '../validation.public' +import type { FieldErrorTarget } from '../../validation/results/results.public' import type { FormValidatorClientContext, FormValidatorPreprocessContext, FormValidatorServerContext, -} from '../validation/formValidators.public' -import type { FieldErrorTarget } from '../validation/state.public' +} from './formValidators.public' +import type { + FieldValidatorContext, + FormGroupValidatorContext, +} from './validation.public' interface ValidatorContextDefinition { run: unknown diff --git a/packages/form-core/src/pipeline/validatorExecutor.lib.ts b/packages/form-core/src/pipeline/validatorExecutor.lib.ts deleted file mode 100644 index 7e9cc7604..000000000 --- a/packages/form-core/src/pipeline/validatorExecutor.lib.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' -import { BaseInstance } from './baseInstance.lib' -import { isStandardSchema, parseStandardSchema } from './standardSchema.lib' -import { mapResult } from './utils.lib' -import type { Awaitable } from '../types.public' -import type { ValidatorScope } from '../validation.public' -import type { StandardSchemaV1 } from '../standardSchema.public' -import type { - FieldErrorTarget, - FormValidationResult, -} from '../validation/state.public' -import type { PipelineInstance, PipelineRun } from './state.lib' - -export interface ValidatorDefinition { - run: - StandardSchemaV1 | ((context: any) => Awaitable>) - fieldErrorTarget?: FieldErrorTarget -} - -export interface ValidatorExecutionResult { - result: FormValidationResult - output: unknown - hasOutput: boolean -} - -function executeDefinition( - definition: ValidatorDefinition, - context: { value: any }, - { scope }: { scope: ValidatorScope }, -): Awaitable { - let result - if (isStandardSchema(definition.run)) { - result = parseStandardSchema(definition.run, context.value, { - scope, - fieldErrorTarget: definition.fieldErrorTarget, - }) - } else { - result = definition.run(context) - } - - return mapResult(result, normalizeValidatorResult) -} - -function normalizeValidatorResult( - result: FormValidationResult, -): ValidatorExecutionResult { - if (isValidationOutput(result)) { - return { - result: null, - output: result[VALIDATION_OUTPUT], - hasOutput: true, - } - } - - return { result, output: null, hasOutput: false } -} - -interface ValidatorRunInput { - createContext: ( - definition: ValidatorDefinition, - signal: AbortSignal, - ) => { value: any } -} - -export class ValidatorExecutor< - TDefinition extends ValidatorDefinition = ValidatorDefinition, -> extends BaseInstance { - definition: TDefinition - readonly validatorScope: ValidatorScope - readonly index: number - - constructor({ - definition, - validatorScope, - index, - }: { - definition: TDefinition - validatorScope: ValidatorScope - index: number - }) { - super() - this.definition = definition - this.validatorScope = validatorScope - this.index = index - } - - execute( - input: ValidatorRunInput, - signal: AbortSignal, - ): Awaitable { - const definition = this.definition - const context = input.createContext(definition, signal) - return executeDefinition(definition, context, { - scope: this.validatorScope, - }) - } -} - -type ValidationRun = Pick, 'pending' | 'cancel'> - -/** - * Track validation runs until they seettle or are cancelled. - * - * Standalone server validation awaits a single run and does not need this tracker. - */ -export class ValidationRunTracker { - private readonly runs = new Set() - - get hasPending(): boolean { - return this.runs.size > 0 - } - - track = (run: ValidationRun): void => { - if (!run.pending || this.runs.has(run)) return - - this.runs.add(run) - const release = () => { - this.runs.delete(run) - } - void run.pending.then(release, release) - } - - cancelAll = (): void => { - const runs = Array.from(this.runs) - this.runs.clear() - - for (const run of runs) { - run.cancel() - } - } -} diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts deleted file mode 100644 index f5ae356c1..000000000 --- a/packages/form-core/src/ssr.lib.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { batch } from '@tanstack/store' -import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' -import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' -import { evaluate } from './utils.lib' -import { runServerValidators } from './pipeline/serverValidation.lib' -import { ValidationResultCollector } from './pipeline/utils.lib' -import { devtools } from './devtoolsBridge.lib' -import type { FormErrorMeta } from './FormApi/formState.lib' -import type { AnyInternalFormApi, InternalFormApi } from './FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { - ServerFormState, - ServerValidateResult, -} from './FormApi/formServerState.public' -import type { FormOptions } from './FormApi/formOptions.public' - -function createInitialFormErrorMeta(): FormErrorMeta { - return { - validationSourceErrors: null, - } -} - -function resetFieldMetaForServerState(form: AnyInternalFormApi) { - visitAllFormFields(form._fieldRootNode, (field) => { - field._defaultValueCache = null - - field._listenerInstances?.forEach((instance) => instance.resetRuntime()) - field._validatorInstances?.forEach((instance) => instance.resetRuntime()) - - const metaAtom = field._atoms.meta - if (metaAtom && metaAtom.get() !== defaultInternalBaseFieldMeta) { - metaAtom.set(defaultInternalBaseFieldMeta) - devtools().updateField?.(field) - } - }) -} - -function resetToServerState( - form: InternalFormApi, - serverState: ServerFormState, - defaultValues: TFormData, -): void { - const values = serverState.values ?? defaultValues - const shouldUpdateDefaultValues = !evaluate( - values, - form._options.defaultValues, - ) - - form._listenerInstances?.forEach((instance) => instance.resetRuntime()) - form._validatorInstances?.forEach((instance) => instance.resetRuntime()) - form._onSubmitSource.resetRuntime() - form._defaultValueCache = null - - if (shouldUpdateDefaultValues) { - form._options = { - ...form._options, - defaultValues: values, - } - } - - batch(() => { - resetFieldMetaForServerState(form) - if (shouldUpdateDefaultValues) { - form._atoms.defaultValuesVersion.set((version) => version + 1) - } - form._atoms.values.set(values) - form._atoms.meta.isDirty.set(false) - form._atoms.meta.touchedFieldCount.set(0) - 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) - form._atoms.meta.isSubmitting.set(false) - form._atoms.meta.isSubmitSuccessful.set(false) - form._atoms.meta.submissionAttempts.set(serverState.submissionAttempts) - - for (const result of serverState.validationResults) { - const validatorInstance = - form._validatorInstances?.[result.validatorIndex] - if (!validatorInstance) continue - - form._processValidationResult({ ...result, validatorInstance }, 'server') - } - }) -} - -export function applyServerState( - form: InternalFormApi, - serverState: ServerFormState | null, - defaultValues: TFormData, -): void { - if (serverState === form._lastServerState) return - - form._lastServerState = serverState - - if (!serverState) { - form._clearFormValidationSource('server') - return - } - - resetToServerState(form, serverState, defaultValues) -} - -export async function validateServerValues< - TFormData, - const TValidatorConfig extends unknown[], - TValidators, - TSubmitReturn, ->( - options: FormOptions< - TFormData, - TValidatorConfig, - TValidators, - TSubmitReturn, - unknown - >, - values: TFormData, -): Promise> { - const validators = options.validators - const validatorCount = validators?.length ?? 0 - const collector = new ValidationResultCollector() - const run = runServerValidators({ - validators, - value: values, - onDeferredResults: collector.collect, - }) - collector.collect(run.syncResults) - await run.pending - - if (collector.hasErrors()) { - return { - success: false, - serverState: { - values, - validationResults: collector - .getResults() - .map(({ instance, value }) => ({ - validatorIndex: instance.index, - ...value, - })), - submissionAttempts: 1, - }, - } - } - - return { - success: true, - values, - validatorOutputs: collector.getOutputs(validatorCount) as any, - } -} diff --git a/packages/form-core/src/submissionAttempt.lib.ts b/packages/form-core/src/submissionAttempt.lib.ts new file mode 100644 index 000000000..62c522aa4 --- /dev/null +++ b/packages/form-core/src/submissionAttempt.lib.ts @@ -0,0 +1,187 @@ +import { batch } from '@tanstack/store' +import { getBy } from './utils.lib' +import { ValidationRunTracker } from './pipeline/runTracker.lib' +import { visitFieldSubtree } from './FieldApi/fieldTraversal.lib' +import type { Atom } from '@tanstack/store' +import type { PipelineRun } from './pipeline/state.lib' +import type { AnyValidatorInstance } from './pipeline/validation/ValidatorInstance.lib' +import type { Awaitable } from './types.public' +import type { InternalRootFieldApi } from './FieldApi/RootFieldApi.lib' +import type { + AnyInternalFieldApi, + InternalFieldValidatorInstance, +} from './FieldApi/FieldApi.lib' + +interface SubmissionField { + /** Field value reference captured when the attempt starts. */ + value: any + /** Validator instances captured when the attempt starts. */ + validators: Array +} + +export interface SubmissionOutcome { + /** Errors returned to the caller when the attempt completes. */ + errors: Array + /** Whether submission succeeded; an empty `errors` array can still mean failure. */ + successful: boolean +} + +interface SubmissionOwner { + submissions: Set> + submissionAttempts: Atom + isSubmitting: Atom + isSubmitSuccessful: Atom +} + +interface SubmissionAttemptOptions { + /** Full form value reference used to capture each participating field's value. */ + values: unknown + /** Submitted form or group value reference passed to validation and callbacks. */ + value: unknown + /** Root or group node whose participating fields are captured. */ + node: AnyInternalFieldApi | InternalRootFieldApi + /** Form or group validator instances to capture, or `null` when none are installed. */ + validators: ReadonlyArray | null + /** Submission registration and state for the form or group. */ + owner: SubmissionOwner +} + +/** Owns one form or group submission's captured input, execution etc. */ +export class SubmissionAttempt { + private phase: 'validating' | 'callbacks' | 'discarded' | 'completed' = + 'validating' + /** + * Whether this attempt can still publish results while active. + * Invalidation clears this flag but lets callbacks already in progress finish. + */ + private attached = true + private readonly runs = new ValidationRunTracker() + /** Fields captured for root submit notifications, including those without validators. */ + readonly notificationFields: Array = [] + /** Field identities and validation inputs captured for nodes with validators. */ + readonly fields = new Map() + + /** Form or group validator instances captured when the attempt starts. */ + readonly validators: Array | null + /** Form or group value captured when the attempt starts. */ + readonly value: any + private readonly owner: SubmissionOwner + + /** Resolves the public promise with returned errors, or `[]` on cancellation. */ + private resolve!: (errors: Array) => void + /** Rejects the public promise with an exception propagated by either step. */ + private reject!: (error: unknown) => void + /** + * Promise returned by `handleSubmit()` for this attempt. + * Resolves with errors (empty on success or cancellation) or rejects on failure. + */ + readonly promise = new Promise>((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + + private finishOwner(successful: boolean): void { + if (!this.owner.submissions.delete(this)) return + this.owner.isSubmitting.set(this.owner.submissions.size > 0) + this.owner.isSubmitSuccessful.set(successful) + } + + /** Settles an active attempt with the callback step's errors and success status. */ + private complete(errors: Array, successful: boolean): void { + if (!this.isActive()) return + this.phase = 'completed' + batch(() => this.finishOwner(successful)) + this.resolve(errors) + } + + /** Rejects an active attempt after a validation or callback step throws or rejects. */ + private fail(error: unknown): void { + if (!this.isActive()) return + this.phase = 'completed' + batch(() => this.finishOwner(false)) + this.reject(error) + } + + constructor(options: SubmissionAttemptOptions) { + this.value = options.value + this.validators = options.validators?.slice() ?? null + this.owner = options.owner + + visitFieldSubtree(options.node, (field) => { + if (options.node._isRoot) this.notificationFields.push(field) + const validators = field._validatorInstances + if (!validators?.length) return + this.fields.set(field, { + value: getBy(options.values, field.name), + validators: validators.slice(), + }) + }) + } + + /** Whether the attempt is still validating or running callbacks. */ + isActive = (): boolean => { + return this.phase === 'validating' || this.phase === 'callbacks' + } + + /** Whether the active attempt is still attached to its owner's current state. */ + canApplyValidationResults = (): boolean => { + return this.attached && this.isActive() + } + + /** + * Runs validation followed by owner-specific callbacks and settles `promise`. + * Cancellation during validation prevents the callback step from starting. + */ + start = (steps: { + /** Runs the owner's validation sequence and supplies the callback step's input. */ + validate: () => Awaitable + /** Interprets validation results and runs submit callbacks to determine the outcome. */ + callbacks: (validation: TValidation) => Awaitable> + }): void => { + void (async () => { + this.owner.submissions.add(this) + batch(() => { + this.owner.submissionAttempts.set((attempts) => attempts + 1) + this.owner.isSubmitting.set(true) + }) + if (!this.isActive()) return + const validation = await steps.validate() + if (!this.isActive()) return + this.phase = 'callbacks' + const outcome = await steps.callbacks(validation) + this.complete(outcome.errors, outcome.successful) + })().catch((error) => this.fail(error)) + } + + /** + * Detaches the attempt from live state and cancels it during validation. + * Callbacks already in progress can still finish and settle the public promise. + */ + invalidate = (): void => { + this.attached = false + if (this.phase === 'validating') this.cancel() + } + + /** + * Tracks participating validation work and returns a function to unregister it. + * Inactive attempts cancel the supplied run immediately. + */ + registerRun = (run: PipelineRun): (() => void) => { + if (!this.isActive()) { + run.cancel() + return () => {} + } + return this.runs.register(run) + } + + /** Discards an active attempt, resolves `promise` with `[]`, and cancels its runs. */ + cancel = (): void => { + if (!this.isActive()) return + this.phase = 'discarded' + batch(() => { + this.finishOwner(false) + this.resolve([]) + this.runs.cancelAll() + }) + } +} diff --git a/packages/form-core/src/types.lib.ts b/packages/form-core/src/types.lib.ts index 3e2191ab3..5f228e718 100644 --- a/packages/form-core/src/types.lib.ts +++ b/packages/form-core/src/types.lib.ts @@ -11,7 +11,10 @@ export interface FieldApiOverrideOptions { } export interface InternalFieldUpdateOptions - extends FieldUpdateOptions, FieldApiOverrideOptions, PropagateOptions { + extends + FieldUpdateOptions, + FieldApiOverrideOptions, + NotifyParentFieldsOptions { /** * @private * Whether to use `tryGetFieldApi` or `getOrCreateFieldApi`. @@ -21,13 +24,13 @@ export interface InternalFieldUpdateOptions _skipFieldCreation?: boolean } -export interface PropagateOptions { +export interface NotifyParentFieldsOptions { /** * @private - * Whether to propagate the action to parent field nodes. - * @default boolean - So far, always true. Maybe a future case needs it to be false. + * Whether to walk up to parent fields to notify their listeners and apply blurred flags. + * @default true */ - doPropagate?: boolean + notifyParentFields?: boolean } export type ResolvedInternalFieldUpdateOptions = diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index c10f6e5ba..86487575b 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -1,16 +1,15 @@ -import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' - // type +import type { + InternalFieldUpdateOptions, + ResolvedInternalFieldUpdateOptions, +} from './types.lib' import type { FieldUpdateOptions, ReadonlyOneOrMany, Updater, } from './types.public' -import type { - InternalFieldUpdateOptions, - ResolvedInternalFieldUpdateOptions, -} from './types.lib' import type { AnyInternalFormApi } from './FormApi/FormApi.lib' +import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' export type NameSegment = string | number export type NameSegments = Array @@ -52,6 +51,16 @@ export function nameToFieldNodeSegments( return result } +export function canonicalFieldName(name: string | NameSegments): string { + return nameToFieldNodeSegments(name) + .map((segment, index) => + typeof segment === 'number' + ? `[${segment}]` + : `${index === 0 ? '' : '.'}${segment}`, + ) + .join('') +} + export function resolveFieldUpdateOptions( options: InternalFieldUpdateOptions | undefined, event: 'change' | 'blur' | 'submit' | 'noEvent', @@ -70,7 +79,7 @@ export function resolveFieldUpdateOptions( ...baseOpts, _skipFieldCreation: options?._skipFieldCreation ?? noFieldCreationNeeded, fieldApiOverride: options?.fieldApiOverride ?? null, - doPropagate: options?.doPropagate ?? true, + notifyParentFields: options?.notifyParentFields ?? true, } } @@ -202,6 +211,11 @@ export function callUpdater(updater: Updater, object: T): T { : updater } +export function clearArray(arr: Array): void { + // Don't you dare use arr.length = 0. I'll find you. ~Luca + arr.splice(0, arr.length) +} + export function normalizeToArray( input: null | undefined | void | ReadonlyOneOrMany, ): ReadonlyArray { @@ -320,8 +334,8 @@ export function isPromiseLike( value: T | PromiseLike, ): value is PromiseLike { return ( - typeof value === 'object' && value !== null && + (typeof value === 'object' || typeof value === 'function') && 'then' in value && typeof value.then === 'function' ) diff --git a/packages/form-core/src/validation/errorStorage.lib.ts b/packages/form-core/src/validation/errorStorage.lib.ts index c675e3dd5..727db96c1 100644 --- a/packages/form-core/src/validation/errorStorage.lib.ts +++ b/packages/form-core/src/validation/errorStorage.lib.ts @@ -1,19 +1,51 @@ import { batch, createAtom } from '@tanstack/store' import { evaluate } from '../utils.lib' -import { sortValidationSources } from '../pipeline/utils.lib' +import type { ValidatorEvents } from '../pipeline/events.public' +import type { ValidatorInstance } from '../pipeline/validation/ValidatorInstance.lib' +import type { ValidationIssue } from './results/results.public' import type { Atom, ReadonlyAtom } from '@tanstack/store' -import type { ValidatorEvents } from '../events.public' -import type { InternalValidationSourceScopePriority } from '../ValidationSourceInstance.lib' -import type { ValidationIssue } from './state.public' + +/** Validation sources ordered by their contribution to derived errors. */ +export const validationSourceScopes = { + field: 0, + group: 1, + form: 2, + onSubmit: 3, +} as const + +export type ValidationErrorSourceScope = keyof typeof validationSourceScopes + +export type ValidationErrorSourceScopePriority = + (typeof validationSourceScopes)[ValidationErrorSourceScope] /** Storage and ordering state shared by executing and non-executing sources. */ -export interface ValidationErrorSource { - readonly scope: InternalValidationSourceScopePriority +export interface ValidationErrorSourceState { + readonly scope: ValidationErrorSourceScopePriority readonly index: number lastAppliedEvent: ValidatorEvents | null errorTargets: Set | null } +/** Error sources narrowed by their numeric scope. */ +export type ValidationErrorSource = + | ValidatorInstance<'field'> + | ValidatorInstance<'group'> + | ValidatorInstance<'form'> + | OnSubmitErrorSource + +/** Stable source for errors returned directly by the form's `onSubmit`. */ +export class OnSubmitErrorSource implements ValidationErrorSourceState { + readonly scope = validationSourceScopes.onSubmit + readonly index = 0 + errorTargets: Set | null = null + lastAppliedEvent: ValidatorEvents | null = null + + /** Clears stored errors while preserving this source's identity. */ + resetRuntime(): void { + removeSourceErrors(this) + } +} + /** A stable field or form-root identity, independent of its current path. */ export interface ValidationErrorTarget { readonly _errorStore: ValidationErrorStore @@ -21,6 +53,28 @@ export interface ValidationErrorTarget { type SourceErrorMap = Map> +export type ValidationErrorStoreState = ReadonlyMap< + ValidationErrorSource, + ReadonlyArray +> | null + +interface ValidationErrorStoreOptions { + target: ValidationErrorTarget + onChange?: (hadErrors: boolean, hasErrors: boolean) => void +} + +/** Returns a copy ordered by numeric scope priority, then original index. */ +export function sortValidationSources< + TSource extends Pick, +>(sources: Iterable): Array { + return Array.from(sources).sort((left, right) => { + if (left.scope === right.scope) { + return left.index - right.index + } + return left.scope - right.scope + }) +} + /** * Takes a target off the source's list once the source has no errors there. * Otherwise, replacing or clearing its errors would keep checking that target @@ -42,32 +96,51 @@ function unlinkErrorTarget( */ function clearSourceErrorsAtTarget( target: ValidationErrorTarget, - source: ValidationErrorSource, + source: ValidationErrorSourceState, ): boolean { - return setSourceErrors(target, source, null) + const errorMap = target._errorStore.atom.get() + if (!errorMap) return false + + const next = new Map(errorMap) + // Removal only needs source identity; stored keys retain their scope types. + const sources: Map< + ValidationErrorSourceState, + ReadonlyArray + > = next + if (!sources.delete(source)) return false + + target._errorStore._setErrorMap(next.size > 0 ? next : null) + return true } /** Owns the local error state independently of field metadata. */ export class ValidationErrorStore { readonly _target: ValidationErrorTarget _cachedErrors: Array | null = null + private readonly onChange: ValidationErrorStoreOptions['onChange'] - readonly atom: ReadonlyAtom - > | null> + readonly atom: ReadonlyAtom - constructor(target: ValidationErrorTarget) { + constructor({ target, onChange }: ValidationErrorStoreOptions) { this._target = target + this.onChange = onChange this.atom = createAtom(null) } /** Publishes a map prepared by the source operations in this module. */ _setErrorMap(sourceErrors: SourceErrorMap | null): void { + const hadErrors = this.hasErrors this._cachedErrors = null // We know the atom is mutable, but users shouldn't do that externally. const writableAtom = this.atom as Atom - writableAtom.set(sourceErrors) + batch(() => { + writableAtom.set(sourceErrors) + this._notifyChange(hadErrors) + }) + } + + _notifyChange(hadErrors = this.hasErrors): void { + this.onChange?.(hadErrors, this.hasErrors) } get errors(): Array { @@ -75,11 +148,13 @@ export class ValidationErrorStore { const sourceErrors = this.atom.get() if (this._cachedErrors === null) { - this._cachedErrors = sourceErrors - ? sortValidationSources(sourceErrors.keys()).flatMap((source) => - sourceErrors.get(source)!, - ) - : [] + if (sourceErrors) { + this._cachedErrors = sortValidationSources(sourceErrors.keys()).flatMap( + (source) => sourceErrors.get(source)!, + ) + } else { + this._cachedErrors = [] + } } return this._cachedErrors } @@ -94,10 +169,9 @@ export class ValidationErrorStore { */ clearStaleErrors( canClearSource: (source: ValidationErrorSource) => boolean, - ): Set { + ): void { const errorMap = this.atom.get() - const affectedTargets = new Set() - if (!errorMap) return affectedTargets + if (!errorMap) return let next: SourceErrorMap | null = null for (const source of errorMap.keys()) { @@ -112,7 +186,7 @@ export class ValidationErrorStore { next ??= new Map(errorMap) next.delete(source) } - if (!next) return affectedTargets + if (!next) return batch(() => { for (const source of errorMap.keys()) { @@ -121,25 +195,20 @@ export class ValidationErrorStore { } } this._setErrorMap(next.size > 0 ? next : null) - affectedTargets.add(this._target) }) - return affectedTargets } /** Clears this target without changing contributions at other targets. */ - clearTargetErrors(): Set { + clearTargetErrors(): void { const errorMap = this.atom.get() - const affectedTargets = new Set() - if (!errorMap) return affectedTargets + if (!errorMap) return batch(() => { for (const source of errorMap.keys()) { unlinkErrorTarget(source, this._target) } this._setErrorMap(null) - affectedTargets.add(this._target) }) - return affectedTargets } } @@ -192,7 +261,9 @@ export function replaceSourceErrors( } const affectedTargets = new Set() + const eventChanged = source.lastAppliedEvent !== event batch(() => { + source.lastAppliedEvent = event for (const target of source.errorTargets ?? []) { if (!nextContributions.has(target)) { const wasChanged = clearSourceErrorsAtTarget(target, source) @@ -205,10 +276,11 @@ export function replaceSourceErrors( const wasChanged = setSourceErrors(target, source, errors) if (wasChanged) { affectedTargets.add(target) + } else if (eventChanged) { + target._errorStore._notifyChange() } } - source.lastAppliedEvent = event if (nextContributions.size > 0) { source.errorTargets = new Set(nextContributions.keys()) } else { @@ -220,7 +292,7 @@ export function replaceSourceErrors( /** Clears every contribution of a removed source. */ export function removeSourceErrors( - source: ValidationErrorSource, + source: ValidationErrorSourceState, ): Set { const affectedTargets = new Set() batch(() => { diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts deleted file mode 100644 index a3e535f6e..000000000 --- a/packages/form-core/src/validation/errors.lib.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { evaluate, normalizeToArray } from '../utils.lib' -import { isSuccessfulValidationResult } from '../validationOutput.lib' -import { compareValidationSources } from '../ValidationSourceInstance.lib' -import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { - FieldValidateResult, - FormGroupValidateResult, - SuccessfulValidationResult, - ValidationErrorInput, -} from '../validation.public' -import type { - FormValidationResult, - ValidationErrorMap, - ValidationFailure, - ValidationIssue, - ValidationSuccess, -} from './state.public' - -type ValidateResult = - FormValidationResult | FormGroupValidateResult | FieldValidateResult - -export function normalizeValidationError( - value: ValidationErrorInput | ValidationFailure | null | undefined | void, -): 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 (isSuccessfulValidationResult(value)) { - 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, - // TODO merge these two after migration -): 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 deleted file mode 100644 index 1221d7a1e..000000000 --- a/packages/form-core/src/validation/execution.lib.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' -import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' -import { - isValidationTriggerEnabled as isTriggerEnabled, - shouldRunValidator as isValidatorEnabled, -} from '../pipeline/utils.lib' -import type { SelectableValidator } from '../pipeline/utils.lib' -import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { - AnyInternalFormApi, - InternalFormApi, -} from '../FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { - FieldValidateResult, - FieldValidator, - FieldValidatorContext, - FormGroupValidateResult, - FormGroupValidator, - FormGroupValidatorContext, - FormValidatorContext, - ServerFormValidatorContext, - ValidationPredicateContext, - ValidationTriggerOption, - Validator, -} from '../validation.public' -import type { FormValidationResult } from './state.public' -import type { FormValidator } from './formValidators.public' - -type FormValidateContext = { - scope: 'form' - event: Exclude['event'], 'server'> - signal: AbortSignal - formApi: AnyInternalFormApi - triggerFieldApi?: AnyInternalFieldApi -} -type ServerPipelineValidateContext = { - scope: 'server' - event: 'server' - signal: AbortSignal - formApi: undefined -} -type FieldValidateContext = Omit< - FieldValidatorContext, - 'value' -> & { scope: 'field' } -type FormGroupValidateContext = Omit< - FormGroupValidatorContext, - 'value' | 'createErrorMap' | 'parseIssues' | 'createOutput' -> & { 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 = - FormValidationResult | 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' -} - -/** - * 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, - } -} - -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 - output: any | null - hasOutput: boolean -} - -interface PendingDebouncedCall { - context: ValidateContext - resolve: ( - value: ValidatorExecutionResult | AbortedCall | ThrownError, - ) => void - reject: (error: unknown) => void -} - -type PipelineValidatorInstance = - AnyInternalValidatorInstance<(call: PendingDebouncedCall) => void> - -function getSelectionContext(context: InputContext) { - return { - event: context.event, - createPredicateContext: isServerContext(context) - ? undefined - : () => 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 { - return isTriggerEnabled(trigger, getSelectionContext(context)) -} - -/** Selects a validator using server, submit, or configured event semantics. */ -export function shouldRunValidator( - validator: AnyPipelineValidator, - context: InputContext, -): boolean { - return isValidatorEnabled( - validator as SelectableValidator, - getSelectionContext(context), - ) -} - -/** - * Executes a validator and normalizes its result for pipeline processing. - * - * Schema and branded function outputs retain their payload and presence marker. - */ -export async function executeValidator( - validatorInstance: AnyInternalValidatorInstance, - context: AnyValidatorContext, -): Promise> { - const validator = validatorInstance.definition - const result = isStandardSchema(validator.run) - ? await parseStandardSchema(validator.run, context.value, validatorInstance) - : await validator.run(context) - - return normalizeValidatorResult(result as TResult) -} - -/** Separates explicitly branded outputs from ordinary validation returns. */ -export function normalizeValidatorResult( - result: TResult, -): ValidatorExecutionResult { - if (isValidationOutput(result)) { - return { - result: null as TResult, - output: result[VALIDATION_OUTPUT], - hasOutput: true, - } - } - - return { result, output: null, hasOutput: 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) - - if (!debouncer) { - settle(ABORTED_CALL) - return - } - - 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 deleted file mode 100644 index f8c5e0b5a..000000000 --- a/packages/form-core/src/validation/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index ef85a2c49..000000000 --- a/packages/form-core/src/validation/mount.lib.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { createOutput } from '../validationOutput.lib' -import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' -import { isPromiseLike } from '../utils.lib' -import { createErrorMap } from './createErrorMap.lib' -import { parseIssuesFactory } from './parseIssues.lib' -import { isErrorResult } from './errors.lib' -import { - createValidatorAbortContext, - normalizeValidatorResult, - shouldRunValidator, -} from './execution.lib' -import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' -import type { - FieldValidateResult, - FormGroupValidateResult, -} from '../validation.public' -import type { - AnyValidatorContext, - InputContext, - ValidateResult, - ValidatorExecutionResult, -} from './execution.lib' -import type { PipelineResult } from './pipeline.lib' -import type { FormValidationResult } from './state.public' - -interface FormMountValidatorPipelineArgs { - pipeline: ReadonlyArray - formApi: AnyInternalFormApi - onResult?: (result: PipelineResult>) => void -} - -export interface FormMountValidatorPipelineResult { - didRun: boolean - asyncPromise: Promise | null -} - -interface MountValidatorPipelineArgs { - pipeline: ReadonlyArray - context: InputContext - getContext: ( - signal: AbortSignal, - instance: AnyInternalValidatorInstance, - ) => AnyValidatorContext - onResult?: (result: PipelineResult) => void -} - -/** - * Creates the neutral result used when mount validation is aborted or throws. - */ -function createEmptyMountValidationResult< - TResult extends ValidateResult, ->(): ValidatorExecutionResult { - return normalizeValidatorResult(null as TResult) -} - -/** - * Publishes one mount execution result and reports whether it contains errors. - * - * Mount outputs remain on the immediate result and are not persisted on - * the validator instance. - */ -function processMountValidationExecutionResult( - validatorInstance: AnyInternalValidatorInstance, - executionResult: ValidatorExecutionResult, - onResult?: (result: PipelineResult) => void, -): boolean { - const result: PipelineResult = { - validatorInstance, - result: executionResult.result, - output: executionResult.output, - hasOutput: executionResult.hasOutput, - } - - 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'], - validatorInstance: AnyInternalValidatorInstance, -): - | ValidatorExecutionResult - | PromiseLike> { - const validator = validatorInstance.definition - const { signal, cleanup } = createValidatorAbortContext(validatorInstance, { - cancelDebouncer: true, - }) - - const context = getContext(signal, validatorInstance) - - try { - if (isStandardSchema(validator.run)) { - return parseStandardSchema( - validator.run, - context.value, - validatorInstance, - ) - .then((result) => { - if (signal.aborted) { - return createEmptyMountValidationResult() - } - - return normalizeValidatorResult(result as TResult) - }) - .catch((error) => { - console.error(error) - return createEmptyMountValidationResult() - }) - .finally(cleanup) - } - - const result = validator.run(context) - - if (isPromiseLike(result)) { - return Promise.resolve(result) - .then((asyncResult): ValidatorExecutionResult => { - if (signal.aborted) { - return createEmptyMountValidationResult() - } - - return normalizeValidatorResult(asyncResult as TResult) - }) - .catch((error) => { - console.error(error) - return createEmptyMountValidationResult() - }) - .finally(cleanup) - } - - cleanup() - return normalizeValidatorResult(result as TResult) - } catch (error) { - cleanup() - console.error(error) - return createEmptyMountValidationResult() - } -} - -/** - * Continues mount validation sequentially after the first asynchronous result. - * - * Eligible validators still honor `bailIfInvalid` in configured order. - */ -async function continueMountValidationFromAsyncResult< - TResult extends ValidateResult, ->( - pipeline: ReadonlyArray, - getContext: MountValidatorPipelineArgs['getContext'], - startIndex: number, - firstResult: PromiseLike>, - hasFailedBefore: boolean, - onResult?: (result: PipelineResult) => void, -): Promise { - let hasFailed = hasFailedBefore - - const firstExecutionResult = await firstResult - if ( - processMountValidationExecutionResult( - pipeline[startIndex]!, - firstExecutionResult, - onResult, - ) - ) { - hasFailed = true - } - - for (let i = startIndex + 1; i < pipeline.length; i++) { - const validatorInstance = pipeline[i]! - const validator = validatorInstance.definition - - if (validator.bailIfInvalid && hasFailed) break - - const result = executeMountValidator(getContext, 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, - context, - getContext, - onResult, -}: MountValidatorPipelineArgs): FormMountValidatorPipelineResult { - const mountPipeline = pipeline.filter((validatorInstance) => - shouldRunValidator(validatorInstance.definition, context), - ) - - if (mountPipeline.length === 0) - return { - didRun: false, - asyncPromise: null, - } - - let hasFailed = false - - for (let i = 0; i < mountPipeline.length; i++) { - const validatorInstance = mountPipeline[i]! - const validator = validatorInstance.definition - - if (validator.bailIfInvalid && hasFailed) { - return { - didRun: true, - asyncPromise: null, - } - } - - const result = executeMountValidator(getContext, validatorInstance) - - if (isPromiseLike(result)) { - return { - didRun: true, - asyncPromise: continueMountValidationFromAsyncResult( - mountPipeline, - getContext, - i, - 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, - context: { scope: 'form', event: 'mount', formApi }, - getContext: (signal, instance) => ({ - event: 'mount', - signal, - formApi, - value: formApi.state.values, - createErrorMap, - parseIssues: parseIssuesFactory( - formApi.state.values, - instance.fieldErrorTarget, - ), - createOutput, - }), - 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, - context: { - scope: 'field', - event: 'mount', - formApi: fieldApi.form, - fieldApi, - }, - getContext: (signal) => ({ - event: 'mount', - signal, - formApi: fieldApi.form as never, - fieldApi: fieldApi as never, - value: fieldApi.value, - }), - 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, - context: { - scope: 'group', - event: 'mount', - formApi: groupApi.form, - groupApi, - }, - getContext: (signal, instance) => ({ - event: 'mount' as const, - signal, - formApi: groupApi.form as never, - groupApi: groupApi as never, - triggerFieldApi: undefined, - value: groupApi.value, - createErrorMap, - parseIssues: parseIssuesFactory( - groupApi.value, - instance.fieldErrorTarget, - ), - createOutput, - }), - onResult, - }) -} diff --git a/packages/form-core/src/validation/parseIssues.lib.ts b/packages/form-core/src/validation/parseIssues.lib.ts deleted file mode 100644 index 22e5fa452..000000000 --- a/packages/form-core/src/validation/parseIssues.lib.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { parseIssuesToErrorMap } from '../standardSchema.lib' -import type { FieldErrorTarget, ParseIssuesFn } from './state.public' - -export const PARSED_ISSUES = Symbol('PARSED_ISSUES') - -export function parseIssuesFactory( - value: TFormData, - routeFieldErrorsTo: FieldErrorTarget, -): ParseIssuesFn { - return (issues) => { - const map = parseIssuesToErrorMap(issues, value) - - // `map.form` is only issues that have no destination. - const form = routeFieldErrorsTo === 'field' ? map.form : issues.slice() - - const fields = routeFieldErrorsTo === 'self' ? {} : map.fields - - return { - [PARSED_ISSUES]: true, - form, - fields, - } - } -} diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts deleted file mode 100644 index 4400e4af8..000000000 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { createOutput } from '../validationOutput.lib' -import { isNotNil } from '../utils.lib' -import { createErrorMap } from './createErrorMap.lib' -import { parseIssuesFactory } from './parseIssues.lib' -import { isErrorResult } from './errors.lib' -import { - ABORTED_CALL, - THROWN_ERROR, - executeValidator, - isFieldValidateContext, - isServerValidateContext, - runMaybeDebouncedValidator, - shouldRunValidator, -} from './execution.lib' -import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { - AbortedCall, - AnyValidatorContext, - FieldInputContext, - FormInputContext, - InputContext, - ThrownError, - ValidateContext, - ValidateResult, - ValidatorExecutionResult, -} from './execution.lib' -import type { FormValidationResult } from './state.public' -import type { FieldValidateResult } from '../validation.public' - -export interface PipelineResult { - validatorInstance: AnyInternalValidatorInstance - result: T - output: any | null - hasOutput?: boolean -} - -interface PendingPipelineResult { - validatorInstance: AnyInternalValidatorInstance - result: T -} - -interface ValidatorPipelineArgs { - context: InputContext - pipeline: ReadonlyArray - hasFailedBefore: boolean - getContext: ( - inputContext: ValidateContext, - instance: AnyInternalValidatorInstance, - ) => AnyValidatorContext - 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 output is committed - * before `onResult` observes the accepted result. - */ -async function flushPendingResults( - pending: PendingPromises, - results: Map>, - shouldCommitOutput: 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, - output: executionResult.output, - hasOutput: executionResult.hasOutput, - } - - if (shouldCommitOutput) { - result.validatorInstance.setOutput(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, - validatorInstancesToRun = null, -}: ValidatorPipelineArgs): Promise<{ - results: Array> - hasErrors: boolean - thrownError: unknown | null -}> { - let pending: PendingPromises = [] - const results = new Map< - AnyInternalValidatorInstance, - PipelineResult - >() - const shouldCommitOutput = - context.event === 'submit' && context.scope !== 'field' - - if (shouldCommitOutput) { - pipeline.forEach((validatorInstance) => { - validatorInstance.cancelExecution() - validatorInstance.clearOutput() - }) - } - - let hasErrors = hasFailedBefore - let thrownError: unknown | null = null - - const flush = async (): Promise => { - const { hasErrors: didError, thrownError: flushedThrownError } = - await flushPendingResults(pending, results, shouldCommitOutput, 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( - validatorInstance, - getContext(ctx, validatorInstance), - ) - }, - }).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, instance) => { - if (isServerValidateContext(ctx)) { - throw new Error('Server validation cannot run through client pipeline') - } - - const formValidationContext = { - event: ctx.event, - formApi: ctx.formApi, - signal: ctx.signal, - value: ctx.formApi.state.values, - createErrorMap, - parseIssues: parseIssuesFactory( - ctx.formApi.state.values, - instance.fieldErrorTarget, - ), - createOutput, - } - - if (!isFieldValidateContext(ctx)) { - return { - ...formValidationContext, - triggerFieldApi: ctx.triggerFieldApi, - } - } - - return { - ...formValidationContext, - fieldApi: ctx.fieldApi, - } - }, - }) -} - -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, - } - }, - validatorInstancesToRun, - }) -} diff --git a/packages/form-core/src/validation/createErrorMap.lib.ts b/packages/form-core/src/validation/results/createErrorMap.lib.ts similarity index 94% rename from packages/form-core/src/validation/createErrorMap.lib.ts rename to packages/form-core/src/validation/results/createErrorMap.lib.ts index 219250c16..6b4a8697a 100644 --- a/packages/form-core/src/validation/createErrorMap.lib.ts +++ b/packages/form-core/src/validation/results/createErrorMap.lib.ts @@ -1,10 +1,10 @@ -import { isReadonlyArray } from '../utils.lib' +import { isReadonlyArray } from '../../utils.lib' import type { CreateErrorMapFn, ValidationErrorMap, ValidationFailure, ValidationFailureItem, -} from './state.public' +} from './results.public' function toArray(item: ValidationFailure | undefined): ValidationFailureItem[] { if (isReadonlyArray(item)) return item.slice() diff --git a/packages/form-core/src/validation/results/errors.lib.ts b/packages/form-core/src/validation/results/errors.lib.ts new file mode 100644 index 000000000..c94cfd0fd --- /dev/null +++ b/packages/form-core/src/validation/results/errors.lib.ts @@ -0,0 +1,124 @@ +import { normalizeToArray } from '../../utils.lib' +import { isSuccessfulValidationResult } from './validationOutput.lib' +import type { + FieldValidateResult, + FormGroupValidateResult, + FormValidationResult, + FormattedValidationError, + FormattedValidationResult, + SuccessfulValidationResult, + ValidationErrorInput, + ValidationErrorMap, + ValidationFailure, + ValidationIssue, + ValidationSuccess, +} from './results.public' + +type ValidateResult = + FormValidationResult | FormGroupValidateResult | FieldValidateResult + +export function normalizeValidationError( + value: ValidationErrorInput | ValidationFailure | null | undefined | void, +): 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 +} + +export function formatValidationResult( + value: FormValidationResult, +): FormattedValidationResult { + if (isSuccessfulValidationResult(value)) { + return value + } + + if (isValidationErrorMap(value)) { + const normalizedSelf = normalizeValidationError(value.form) + const subfields: Record> = {} + + for (const [fieldName, fieldError] of Object.entries< + ValidationFailure | undefined + >(value.fields)) { + const normalizedFieldError = normalizeValidationError(fieldError) + + if (normalizedFieldError.length > 0) { + subfields[fieldName] = normalizedFieldError + } + } + + return { form: normalizedSelf, fields: subfields } + } + + return normalizeValidationError(value) +} + +export function getValidationErrors( + value: FormValidationResult, +): Array> { + const formatted = formatValidationResult(value) + if (isSuccessfulValidationResult(formatted)) return [] + if (Array.isArray(formatted)) return formatted + + if ( + formatted.form.length === 0 && + Object.keys(formatted.fields).length === 0 + ) { + return [] + } + return [formatted] +} + +/** Splits formatted errors into errors for the owner and its subfields. */ +export function parseValidationResult( + value: ValidateResult, +): ParsedValidationResult { + const formatted = formatValidationResult(value) + if (isSuccessfulValidationResult(formatted)) { + return { self: null, subfields: null } + } + + const self = Array.isArray(formatted) ? formatted : formatted.form + + return { + self: self.length > 0 ? self : null, + subfields: Array.isArray(formatted) + ? null + : (formatted.fields as Record>), + } +} + +/** + * Checks whether a validation result contains an error that would be stored. + */ +export function isErrorResult( + value: T, + // TODO merge these two after migration +): value is Exclude { + return getValidationErrors(value).length > 0 +} diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/validation/results/parseIssues.lib.ts similarity index 51% rename from packages/form-core/src/standardSchema.lib.ts rename to packages/form-core/src/validation/results/parseIssues.lib.ts index 6e304c6d2..221652a62 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/validation/results/parseIssues.lib.ts @@ -1,22 +1,10 @@ -import { isNil } from './utils.lib' -import { createOutput } from './validationOutput.lib' -import { PARSED_ISSUES, parseIssuesFactory } from './validation/parseIssues.lib' -import { validationSourceScopes } from './ValidationSourceInstance.lib' +import { isNil } from '../../utils.lib' +import type { StandardSchemaV1Issue } from './standardSchema.public' import type { - StandardSchemaV1, - StandardSchemaV1Issue, -} from './standardSchema.public' -import type { - FieldValidatorFn, - FormGroupValidatorFn, -} from './validation.public' -import type { FormValidatorFn } from './validation/formValidators.public' -import type { - ParsedStandardSchemaIssues, + FieldErrorTarget, + ParseIssuesFn, ValidationFieldMap, - ValidationOutput, -} from './validation/state.public' -import type { AnyInternalValidatorInstance } from './ValidatorInstance.lib' +} from './results.public' export function parseIssuesToErrorMap( issues: ReadonlyArray, @@ -78,36 +66,24 @@ function getPathFromIssue( return path } -export function isStandardSchema( - object: - | FormValidatorFn - | FormGroupValidatorFn - | FieldValidatorFn - | StandardSchemaV1, -): object is StandardSchemaV1 { - // Arktype typeof is function, not object. This is the only constraint that's safe so far. - return '~standard' in object -} +export const PARSED_ISSUES = Symbol('PARSED_ISSUES') -export function parseStandardSchema( - schema: StandardSchemaV1, - value: any, - instance: AnyInternalValidatorInstance, -): Promise< - | ValidationOutput - | ParsedStandardSchemaIssues - | ReadonlyArray -> { - const parseIssues = parseIssuesFactory(value, instance.fieldErrorTarget) +export function parseIssuesFactory( + value: TFormData, + routeFieldErrorsTo: FieldErrorTarget, +): ParseIssuesFn { + return (issues) => { + const map = parseIssuesToErrorMap(issues, value) - return Promise.resolve(schema['~standard'].validate(value)).then((result) => { - if (!result.issues) { - return createOutput(result.value) - } + // `map.form` is only issues that have no destination. + const form = routeFieldErrorsTo === 'field' ? map.form : issues.slice() + + const fields = routeFieldErrorsTo === 'self' ? {} : map.fields - if (instance.scope === validationSourceScopes.field) { - return result.issues + return { + [PARSED_ISSUES]: true, + form, + fields, } - return parseIssues(result.issues) - }) + } } diff --git a/packages/form-core/src/validation/results/results.public.ts b/packages/form-core/src/validation/results/results.public.ts new file mode 100644 index 000000000..e41e88fc7 --- /dev/null +++ b/packages/form-core/src/validation/results/results.public.ts @@ -0,0 +1,189 @@ +import type { VALIDATION_OUTPUT } from './validationOutput.lib' +import type { OneOrMany, ReadonlyOneOrMany } from '../../types.public' +import type { DeepKeys } from '../../deep-keys.public' +import type { StandardSchemaV1Issue } from './standardSchema.public' +import type { PARSED_ISSUES } from './parseIssues.lib' + +export interface ValidationIssue { + message: string +} + +export type FieldErrorTarget = 'field' | 'field-and-self' | 'self' + +/** + * The result of `createOutput`. Return it from a validator to store its data + * during submission. + * + * @typeParam TOutput - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * run: ({ createOutput, value }) => { + * return createOutput({ displayName: value.name.trim() }) + * }, + * }, + * { + * // Schemas pass their output data by default. + * run: mySchema, + * }, + * ], + * onSubmit: ({ validatorOutputs }) => { + * // Outputs follow the same order as the validators array. + * const [customData, schemaOutput] = validatorOutputs + * console.log(customData.displayName) + * }, + * }) + * ``` + */ +export interface ValidationOutput { + readonly [VALIDATION_OUTPUT]: TOutput +} + +export type ValidationFailureItem = ValidationIssue | string +export type ValidationFailure< + TItem extends ValidationFailureItem = ValidationFailureItem, +> = ReadonlyOneOrMany +export type ValidationSuccess = + null | undefined | false | void | ValidationOutput + +export type ValidationFieldMap = Partial< + Record, TError> +> + +export interface ValidationErrorMap< + TFormData, + TFormError extends ValidationFailure, + TFieldError extends ValidationFailure, +> { + form?: TFormError + fields: ValidationFieldMap +} + +export interface ValidationErrorMapHelper extends Required< + ValidationErrorMap< + TFormData, + ValidationFailureItem[], + ValidationFailureItem[] + > +> { + addFormError: (error: ValidationFailure) => void + addFieldError: (name: DeepKeys, error: ValidationFailure) => void +} + +export type FormValidationFailure = + | ValidationFailure + | ValidationErrorMap + +export type FormValidationResult = + FormValidationFailure | ValidationSuccess + +/** + * Form and field errors after strings have been converted to issue objects. + * Contains error data without the methods supplied by error-map helpers. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +export interface FormattedValidationErrorMap { + form: Array + fields: ValidationFieldMap> +} + +/** + * One entry in the flat errors returned by validation or submission. + * Field paths remain attached to errors supplied as an error map. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +export type FormattedValidationError = + ValidationIssue | FormattedValidationErrorMap + +/** + * A validator result with formatted error contents. Successful results retain + * their original value; ordinary errors are arrays of issue objects. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +export type FormattedValidationResult = + | Array + | FormattedValidationErrorMap + | ValidationSuccess + +export interface ParsedStandardSchemaIssues extends Required< + ValidationErrorMap< + TFormData, + Array, + Array + > +> { + readonly [PARSED_ISSUES]: true +} + +type ErrorMapItem = unknown extends TError + ? ValidationFailureItem + : TError extends ReadonlyArray + ? ErrorMapItem + : TError extends string + ? string + : TError + +export type InferredErrorMapItem = [ErrorMapItem] extends [ + never, +] + ? ValidationFailureItem + : ErrorMapItem + +export type CreateErrorMapFn = ( + initial?: Partial< + ValidationErrorMap + >, +) => ValidationErrorMapHelper + +export type ParseIssuesFn = ( + issues: ReadonlyArray, +) => ParsedStandardSchemaIssues + +export type CreateOutputFn = ( + value: TOutput, +) => ValidationOutput + +/** An error supplied as an issue object or a string that becomes its `message`. */ +export type ValidationErrorValue = ValidationIssue | string +/** One or more issue objects after string errors have been normalized. */ +export type ValidationError = OneOrMany +/** One or more errors accepted from a validator, including string messages. */ +export type ValidationErrorInput = ReadonlyOneOrMany + +/** A validator return value that indicates success without providing output data. */ +export type ValidValidationResult = null | undefined | false + +/** A validator return value that indicates success, with or without output data. */ +export type SuccessfulValidationResult = + ValidValidationResult | ValidationOutput + +/** + * One or more validation errors, or `null`, `undefined`, or `false` for success. + */ +export type ValidationResult = ValidValidationResult | ValidationErrorInput + +/** + * Errors for a form group, optionally including errors at group-relative field paths. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ +export type FormGroupValidationError = + | ValidationErrorInput + | ValidationErrorMap +/** + * A form group validator's success result, output data, or validation errors. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ +export type FormGroupValidateResult = + SuccessfulValidationResult | FormGroupValidationError + +/** A field validator's success result or errors. Field validators do not provide output data. */ +export type FieldValidateResult = ValidationResult diff --git a/packages/form-core/src/standardSchema.public.ts b/packages/form-core/src/validation/results/standardSchema.public.ts similarity index 100% rename from packages/form-core/src/standardSchema.public.ts rename to packages/form-core/src/validation/results/standardSchema.public.ts diff --git a/packages/form-core/src/validationOutput.lib.ts b/packages/form-core/src/validation/results/validationOutput.lib.ts similarity index 86% rename from packages/form-core/src/validationOutput.lib.ts rename to packages/form-core/src/validation/results/validationOutput.lib.ts index 5e342a34b..1aad9149f 100644 --- a/packages/form-core/src/validationOutput.lib.ts +++ b/packages/form-core/src/validation/results/validationOutput.lib.ts @@ -1,7 +1,4 @@ -import type { - ValidationOutput, - ValidationSuccess, -} from './validation/state.public' +import type { ValidationOutput, ValidationSuccess } from './results.public' export const VALIDATION_OUTPUT = Symbol('VALIDATION_OUTPUT') diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index cc8b62387..94d936c10 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -1,127 +1,18 @@ -import type { DeepKeys } from '../deep-keys.public' -import type { OnSubmitError } from '../FormApi/formSubmit.public' +import type { + SuccessfulValidationResult, + ValidationErrorMap, + ValidationIssue, + ValidationSuccess, +} from './results/results.public' import type { StandardSchemaV1, StandardSchemaV1Issue, -} from '../standardSchema.public' -import type { ReadonlyOneOrMany } from '../types.public' -import type { VALIDATION_OUTPUT } from '../validationOutput.lib' -import type { PARSED_ISSUES } from './parseIssues.lib' - -export interface ValidationIssue { - message: string -} - -export type FieldErrorTarget = 'field' | 'field-and-self' | 'self' - -/** - * The result of `createOutput`. Return it from a validator to store its data - * during submission. - * - * @typeParam TOutput - Library-managed. Do not specify explicitly. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { name: '' }, - * validators: [ - * { - * run: ({ createOutput, value }) => { - * return createOutput({ displayName: value.name.trim() }) - * }, - * }, - * { - * // Schemas pass their output data by default. - * run: mySchema, - * }, - * ], - * onSubmit: ({ validatorOutputs }) => { - * // Outputs follow the same order as the validators array. - * const [customData, schemaOutput] = validatorOutputs - * console.log(customData.displayName) - * }, - * }) - * ``` - */ -export interface ValidationOutput { - readonly [VALIDATION_OUTPUT]: TOutput -} - -export type ValidationFailureItem = ValidationIssue | string -export type ValidationFailure< - TItem extends ValidationFailureItem = ValidationFailureItem, -> = ReadonlyOneOrMany -export type ValidationSuccess = - null | undefined | false | void | ValidationOutput - -export type ValidationFieldMap = Partial< - Record, TError> -> - -export interface ValidationErrorMap< - TFormData, - TFormError extends ValidationFailure, - TFieldError extends ValidationFailure, -> { - form?: TFormError - fields: ValidationFieldMap -} - -export interface ValidationErrorMapHelper extends Required< - ValidationErrorMap< - TFormData, - ValidationFailureItem[], - ValidationFailureItem[] - > -> { - addFormError: (error: ValidationFailure) => void - addFieldError: (name: DeepKeys, error: ValidationFailure) => void -} - -export type FormValidationFailure = - | ValidationFailure - | ValidationErrorMap - -export type FormValidationResult = - FormValidationFailure | ValidationSuccess - -export interface ParsedStandardSchemaIssues extends Required< - ValidationErrorMap< - TFormData, - Array, - Array - > -> { - readonly [PARSED_ISSUES]: true -} - -type ErrorMapItem = unknown extends TError - ? ValidationFailureItem - : TError extends ReadonlyArray - ? ErrorMapItem - : TError extends string - ? string - : TError - -export type InferredErrorMapItem = [ErrorMapItem] extends [ - never, -] - ? ValidationFailureItem - : ErrorMapItem - -export type CreateErrorMapFn = ( - initial?: Partial< - ValidationErrorMap - >, -) => ValidationErrorMapHelper - -export type ParseIssuesFn = ( - issues: ReadonlyArray, -) => ParsedStandardSchemaIssues - -export type CreateOutputFn = ( - value: TOutput, -) => ValidationOutput +} from './results/standardSchema.public' +import type { OnSubmitError } from '../FormApi/formSubmit.public' +import type { + FieldValidators, + FormGroupValidators, +} from '../pipeline/validation/validation.public' // ============================ // Error state @@ -235,3 +126,163 @@ export type GetSharedFormErrorTypes = unknown extends TSubmitReturn ? any : GetFormErrorTypes + +type NormalizeValidationResult = NormalizeValidationError< + Exclude +> + +type NormalizeValidationError = + TError extends ReadonlyArray + ? NormalizeValidationError + : TError extends string + ? ValidationIssue + : TError extends ValidationIssue + ? TError + : ValidationIssue + +type ValidationErrorTarget = 'form' | 'field' + +type ExtractErrorMap< + TResult, + TTarget extends ValidationErrorTarget, +> = TResult extends ValidationIssue + ? NormalizeValidationResult + : TResult extends ValidationErrorMap + ? TTarget extends 'form' + ? TResult extends { form?: infer TError } + ? NormalizeValidationResult + : never + : TResult extends { fields: infer TFields } + ? NormalizeValidationResult + : never + : NormalizeValidationResult + +type ExtractSubmitValidationError = + Awaited extends infer TResolved + ? TResolved extends OnSubmitError + ? TError + : never + : never + +type ParseSubmitFormError = ExtractErrorMap< + ExtractSubmitValidationError, + 'form' +> + +type ParseSubmitFieldError = ExtractErrorMap< + ExtractSubmitValidationError, + 'field' +> + +type ExtractSubmitFormError = unknown extends TSubmitReturn + ? ValidationIssue + : ParseSubmitFormError + +type ExtractSubmitFieldError = unknown extends TSubmitReturn + ? ValidationIssue + : ParseSubmitFieldError + +type ExtractFormFieldError = + unknown extends TFormError['subfieldError'] + ? never + : TFormError['subfieldError'] + +/** + * A field's error array, with string messages normalized to issue objects. + * + * @typeParam TFieldError - Library-managed. Do not specify explicitly. + */ +export type FieldErrors = Array< + unknown extends TFieldError ? ValidationIssue : TFieldError +> + +type TryGetFormError = TValidator extends { + readonly run: StandardSchemaV1 +} + ? StandardSchemaV1Issue + : TValidator extends { readonly run: (...args: any) => infer TReturn } + ? ExtractErrorMap, 'form'> + : never + +type TryGetFieldError = TValidator extends { + readonly run: StandardSchemaV1 +} + ? StandardSchemaV1Issue + : TValidator extends { readonly run: (...args: any) => infer TReturn } + ? ExtractErrorMap, 'field'> + : never + +type ExtractValidatorFormError< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, +> = unknown extends TValidators + ? never + : TBroadValidators extends TValidators + ? never + : TryGetFormError + +type ExtractValidatorFieldError< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, +> = unknown extends TValidators + ? never + : TBroadValidators extends TValidators + ? never + : TryGetFieldError + +type GetValidatorSelfErrors< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, + TSubmitReturn, +> = + | ExtractValidatorFormError + | ExtractSubmitFormError + +type GetValidatorSubfieldErrors< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, + TSubmitReturn, +> = + | ExtractValidatorFieldError + | ExtractSubmitFieldError + +type ToValidatorErrorTypes< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, + TSubmitReturn, +> = ScopedErrorTypes< + GetValidatorSelfErrors, + GetValidatorSubfieldErrors +> + +/** + * Infers group and routed field issue types from group validators. + * + * @typeParam TGroupValidators - Library-managed. Do not specify explicitly. + */ +export type ToFormGroupErrorTypes< + TGroupValidators extends FormGroupValidators, +> = ToValidatorErrorTypes, never> + +type FallbackToValidationIssue = [TFieldError] extends [never] + ? ValidationIssue + : TFieldError + +/** + * Combines a field's own issue types with errors routed from its group and form. + * + * Falls back to `ValidationIssue` when no specific error type is inferred. + * + * @typeParam TFieldValidators - Library-managed. Do not specify explicitly. + * @typeParam TGroupFieldError - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. + */ +export type ToFieldError< + TFieldValidators extends FieldValidators, + TGroupFieldError, + TFormError extends ScopedErrorTypes, +> = FallbackToValidationIssue< + | ExtractValidatorFieldError> + | (unknown extends TGroupFieldError ? never : TGroupFieldError) + | ExtractFormFieldError +> diff --git a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts index 9a000ec96..1baecb8c5 100644 --- a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts +++ b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts @@ -2,11 +2,9 @@ import { getBy, isStandardSchema } from '@tanstack/form-core/internals' import { compareFieldPaths } from '../utils' import type { AnyInternalFieldApi, - AnyInternalValidatorInstance, - FieldWatchingListenerFields, - FieldWatchingValidatorFields, + AnyValidatorInstance, InternalFieldState, - ValidationSourceErrorMap, + ValidationErrorStoreState, } from '@tanstack/form-core/internals' import type { ValidationIssue } from '@tanstack/form-core' import type { @@ -51,8 +49,8 @@ function appendValidatorErrors({ mode, }: { destination: Array - errorMap: ValidationSourceErrorMap | null - validatorInstances: ReadonlyArray | null + errorMap: ValidationErrorStoreState + validatorInstances: ReadonlyArray | null getSource: ( validatorIndex: number, sourceEvent: string, @@ -60,10 +58,9 @@ function appendValidatorErrors({ mode: FieldErrorPayloadMode }): void { validatorInstances?.forEach((validatorInstance, validatorIndex) => { - const errorState = errorMap?.get(validatorInstance) - if (!errorState) return - - const { errors, sourceEvent } = errorState + const errors = errorMap?.get(validatorInstance) + const sourceEvent = validatorInstance.lastAppliedEvent + if (!errors || !sourceEvent) return const source = getSource(validatorIndex, sourceEvent) for (const error of errors) { @@ -78,17 +75,16 @@ function appendValidatorErrors({ export function getDevtoolsFieldErrors( field: AnyInternalFieldApi, - state: InternalFieldState, mode: FieldErrorPayloadMode, ): Array { - // Keep this traversal aligned with getErrorsFromBaseMeta in form-core so + // Keep this traversal aligned with the core error store so // these entries have the same order as field.state.meta.original.errors. const errors: Array = [] - const meta = state.meta + const errorMap = field._errorStore.atom.get() appendValidatorErrors({ destination: errors, - errorMap: meta._validationSourceErrors, + errorMap: errorMap, validatorInstances: field._validatorInstances, mode, getSource: (validatorIndex) => ({ @@ -100,12 +96,12 @@ export function getDevtoolsFieldErrors( }), }) - if (meta._validationSourceErrors) { + if (errorMap) { const containingGroup = field._getFormGroup() appendValidatorErrors({ destination: errors, - errorMap: meta._validationSourceErrors, + errorMap: errorMap, validatorInstances: containingGroup?._validatorInstances ?? null, mode, getSource: (validatorIndex) => ({ @@ -123,7 +119,7 @@ export function getDevtoolsFieldErrors( appendValidatorErrors({ destination: errors, - errorMap: meta._validationSourceErrors, + errorMap: errorMap, validatorInstances: field.form._validatorInstances, mode, getSource: (validatorIndex) => ({ @@ -135,11 +131,10 @@ export function getDevtoolsFieldErrors( }), }) - const onSubmitErrorState = meta._validationSourceErrors?.get( - field.form._onSubmitSource, - ) + const onSubmitErrorState = errorMap?.get(field.form._onSubmitSource) if (onSubmitErrorState) { - const { errors: submitErrors, sourceEvent } = onSubmitErrorState + const submitErrors = onSubmitErrorState + const sourceEvent = field.form._onSubmitSource.lastAppliedEvent! for (const error of submitErrors) { errors.push({ error: projectError(error, mode), @@ -160,7 +155,6 @@ function getDevtoolsFieldState( const meta = state.meta const originalErrors = getDevtoolsFieldErrors( field, - state, descriptor.settings.errorPayloadMode, ) @@ -233,118 +227,60 @@ function addRelation( }) } -function addListenerListensToRelations( +function addForwardRelations( relations: Map, field: AnyInternalFieldApi, + kind: DevtoolsFieldRelationKind, identity: Pick, ): void { - field._listenerInstances?.forEach((listenerInstance, itemIndex) => { - listenerInstance.resolvedWatchFields?.forEach( - (sourceField, configuredPath) => { - addRelation( - relations, - sourceField, - getRelationCause( - 'listener', - itemIndex, - configuredPath, - sourceField.name, - ), - identity, - ) - }, - ) - }) -} - -function addListenerListenedToByRelations( - relations: Map, - sourceField: AnyInternalFieldApi, - watchingFields: FieldWatchingListenerFields | null, - identity: Pick, -): void { - watchingFields?.forEach((listenerInstances, watchingField) => { - if (watchingField._isKilled) return - - for (const listenerInstance of listenerInstances) { - const itemIndex = - watchingField._listenerInstances?.indexOf(listenerInstance) ?? -1 - if (itemIndex < 0) continue - - let configuredPath: string | undefined - listenerInstance.resolvedWatchFields?.forEach((field, path) => { - if (field === sourceField) configuredPath = path - }) + const instances = + kind === 'listener' ? field._listenerInstances : field._validatorInstances + instances?.forEach((instance, itemIndex) => { + for (const configuredPath of new Set( + instance.definition.watchFields ?? [], + )) { + const sourceField = field.form._tryGetFieldApi(configuredPath) + if (!sourceField) continue addRelation( relations, - watchingField, - getRelationCause( - 'listener', - itemIndex, - configuredPath, - sourceField.name, - ), + sourceField, + getRelationCause(kind, itemIndex, configuredPath, sourceField.name), identity, ) } }) } -function addValidatorListensToRelations( +function addReverseRelations( relations: Map, - field: AnyInternalFieldApi, + sourceField: AnyInternalFieldApi, + kind: DevtoolsFieldRelationKind, identity: Pick, ): void { - field._validatorInstances?.forEach((validatorInstance, itemIndex) => { - validatorInstance.resolvedWatchFields?.forEach( - (sourceField, configuredPath) => { + const registry = + kind === 'listener' + ? sourceField.form._watchingListenerFields + : sourceField.form._watchingValidatorFields + for (const watchingField of registry.get(sourceField.name) ?? []) { + if (watchingField._isKilled) continue + const instances = + kind === 'listener' + ? watchingField._listenerInstances + : watchingField._validatorInstances + instances?.forEach((instance, itemIndex) => { + for (const configuredPath of new Set( + instance.definition.watchFields ?? [], + )) { + if (configuredPath !== sourceField.name) continue addRelation( relations, - sourceField, - getRelationCause( - 'validator', - itemIndex, - configuredPath, - sourceField.name, - ), + watchingField, + getRelationCause(kind, 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( @@ -381,20 +317,10 @@ function getDevtoolsFieldRelations( const listensTo = new Map() const listenedToBy = new Map() - addListenerListensToRelations(listensTo, field, identity) - addValidatorListensToRelations(listensTo, field, identity) - addListenerListenedToByRelations( - listenedToBy, - field, - field._watchingListenerFields, - identity, - ) - addValidatorListenedToByRelations( - listenedToBy, - field, - field._watchingValidatorFields, - identity, - ) + addForwardRelations(listensTo, field, 'listener', identity) + addForwardRelations(listensTo, field, 'validator', identity) + addReverseRelations(listenedToBy, field, 'listener', identity) + addReverseRelations(listenedToBy, field, 'validator', identity) return { directChildCount: field._children.reduce( diff --git a/packages/form-devtools/src/bridge/fields/fieldDebug/schemaErrorsOnUnmountedDescendants.ts b/packages/form-devtools/src/bridge/fields/fieldDebug/schemaErrorsOnUnmountedDescendants.ts index 51b0f5855..4c5f14b61 100644 --- a/packages/form-devtools/src/bridge/fields/fieldDebug/schemaErrorsOnUnmountedDescendants.ts +++ b/packages/form-devtools/src/bridge/fields/fieldDebug/schemaErrorsOnUnmountedDescendants.ts @@ -11,11 +11,9 @@ export const schemaErrorsOnUnmountedDescendants = { return } - const hasSchemaError = getDevtoolsFieldErrors( - candidate, - candidate.state, - 'messages', - ).some(({ source }) => source.validatorType === 'schema') + const hasSchemaError = getDevtoolsFieldErrors(candidate, 'messages').some( + ({ source }) => source.validatorType === 'schema', + ) if (hasSchemaError) unmountedDescendantPaths.push(candidate.name) }) diff --git a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts index ef075543e..b00a70ca2 100644 --- a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts +++ b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts @@ -1,4 +1,4 @@ -import type { AnyInternalValidatorInstance } from '@tanstack/form-core/internals' +import type { AnyValidatorInstance } from '@tanstack/form-core/internals' import type { ValidatorsWithoutTriggersSuspicion } from '../../../eventClientTypes' import type { FieldDebugCase } from './types' @@ -7,7 +7,7 @@ type ValidatorLocation = function appendValidatorsWithoutTriggers( locations: Array, - validatorInstances: ReadonlyArray | null, + validatorInstances: ReadonlyArray | null, getLocation: (validatorIndex: number) => ValidatorLocation, ): void { validatorInstances?.forEach(({ definition: validator }, validatorIndex) => { diff --git a/packages/form-devtools/src/bridge/fields/index.ts b/packages/form-devtools/src/bridge/fields/index.ts index 735b83ddf..715ba93c8 100644 --- a/packages/form-devtools/src/bridge/fields/index.ts +++ b/packages/form-devtools/src/bridge/fields/index.ts @@ -35,37 +35,15 @@ interface FieldsController { ) => Array } -function addListenerForwardRelations( +function addWatchersAtPath( fields: Set, - field: AnyInternalFieldApi, + form: AnyInternalFormApi, + path: string, ): void { - field._listenerInstances?.forEach((listenerInstance) => { - listenerInstance.resolvedWatchFields?.forEach((sourceField) => - fields.add(sourceField), - ) - }) -} - -function addValidatorForwardRelations( - fields: Set, - field: AnyInternalFieldApi, -): void { - field._validatorInstances?.forEach((validatorInstance) => { - validatorInstance.resolvedWatchFields?.forEach((sourceField) => - fields.add(sourceField), - ) - }) -} - -function addReverseRelations( - fields: Set, - relationGroups: - | AnyInternalFieldApi['_watchingListenerFields'] - | AnyInternalFieldApi['_watchingValidatorFields'], -): void { - relationGroups?.forEach((_indexes, watchingField) => { - fields.add(watchingField) - }) + for (const owner of form._watchingListenerFields.get(path) ?? []) + fields.add(owner) + for (const owner of form._watchingValidatorFields.get(path) ?? []) + fields.add(owner) } function addRelationNeighborhood( @@ -73,10 +51,15 @@ function addRelationNeighborhood( field: AnyInternalFieldApi, ): void { fields.add(field) - addListenerForwardRelations(fields, field) - addValidatorForwardRelations(fields, field) - addReverseRelations(fields, field._watchingListenerFields) - addReverseRelations(fields, field._watchingValidatorFields) + for (const path of field._watchedListenerNames ?? []) { + const source = field.form._tryGetFieldApi(path) + if (source) fields.add(source) + } + for (const path of field._watchedValidatorNames ?? []) { + const source = field.form._tryGetFieldApi(path) + if (source) fields.add(source) + } + addWatchersAtPath(fields, field.form, field.name) } export function createFieldsController( @@ -115,7 +98,10 @@ export function createFieldsController( if (form) identity.deleteForm(form) }, mountField: fieldList.fieldMounted, - updateField: fieldList.fieldUpdated, + updateField: (field) => { + fieldList.fieldUpdated(field) + fieldDetails.fieldsUpdated([field]) + }, fieldAdded: (field) => { fieldList.fieldUpdated(field) @@ -126,8 +112,9 @@ export function createFieldsController( }, fieldDependenciesChanged: (changes) => { const updatedFields = new Set() - for (const { sourceField, watchingField } of changes) { - updatedFields.add(sourceField) + for (const { sourceFieldName, watchingField } of changes) { + const sourceField = watchingField.form._tryGetFieldApi(sourceFieldName) + if (sourceField) updatedFields.add(sourceField) updatedFields.add(watchingField) } fieldDetails.fieldsUpdated(updatedFields) @@ -138,6 +125,11 @@ export function createFieldsController( visitFieldSubtree(field, (movedField) => { fieldList.fieldMoved(movedField, previousPath) addRelationNeighborhood(updatedFields, movedField) + addWatchersAtPath( + updatedFields, + field.form, + previousPath + movedField.name.slice(field.name.length), + ) }) fieldDetails.fieldsUpdated(updatedFields) }, @@ -145,6 +137,8 @@ export function createFieldsController( const removedFields = fields.map(({ field }) => field) const removedFieldSet = new Set(removedFields) const updatedParents = new Set() + for (const { previousPath } of fields) + addWatchersAtPath(updatedParents, form, previousPath) for (const removedField of removedFields) { const parent = removedField._parent if ( diff --git a/packages/form-devtools/tests/testUtils.ts b/packages/form-devtools/tests/testUtils.ts index 7376a1449..be75bc894 100644 --- a/packages/form-devtools/tests/testUtils.ts +++ b/packages/form-devtools/tests/testUtils.ts @@ -1,13 +1,15 @@ -import { reconcileValidatorInstances } from '@tanstack/form-core/internals' +import { ValidatorInstance } 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', + sourceEvent: Parameters< + AnyInternalFieldApi['_processValidationResult'] + >[1] = 'change', ): void { - field._validatorInstances ??= reconcileValidatorInstances({ + field._validatorInstances ??= ValidatorInstance.update({ definitions: [{ run: () => null, triggers: ['change'] }], instances: null, owner: field, @@ -15,11 +17,11 @@ export function setFieldValidatorErrors( }) const validatorInstance = field._validatorInstances![0]! - field._setMeta((meta) => ({ - ...meta, - _validationSourceErrors: - errors.length > 0 - ? new Map([[validatorInstance, { errors, sourceEvent }]]) - : null, - })) + field._processValidationResult( + { + instance: validatorInstance, + value: { result: errors, output: null, hasOutput: false }, + }, + sourceEvent, + ) } diff --git a/packages/react-form-start/src/utils.ts b/packages/react-form-start/src/utils.ts index ff927dd99..b2395c1d8 100644 --- a/packages/react-form-start/src/utils.ts +++ b/packages/react-form-start/src/utils.ts @@ -11,22 +11,7 @@ const INTERNALS_COOKIE_NAME = '_tanstack_form_internals' export function setInternalTanStackCookie( serverState: ServerFormState, ): void { - const state = { - ...serverState, - validationResults: serverState.validationResults.map((validation) => { - const { result } = validation - return { - ...validation, - // Omit the helpers that come with some error maps - result: - result && typeof result === 'object' && 'fields' in result - ? { form: result.form, fields: result.fields } - : result, - } - }), - } - - setCookie(INTERNALS_COOKIE_NAME, stringify(state)) + setCookie(INTERNALS_COOKIE_NAME, stringify(serverState)) } export function getInternalTanStackCookie(): ServerFormState | undefined { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af25c3421..e28f85590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2269,9 +2269,6 @@ importers: packages/form-core: dependencies: - '@tanstack/pacer-lite': - specifier: ^0.2.2 - version: 0.2.2 '@tanstack/store': specifier: ^0.11.1 version: 0.11.1 From 3779151760fa29a0a3b8d45316461e2479af1939 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:53:05 +0100 Subject: [PATCH 13/15] chore: fix and add unit tests --- .../form-core/src/FieldApi/FieldApi.lib.ts | 22 +- .../tests/FieldApi/FieldApi.lib.spec.ts | 2108 ++++++++++++++--- .../form-core/tests/FieldApi/meta.spec.ts | 218 -- .../tests/FormApi/FormApi.lib.spec.ts | 56 +- .../FormApi/formOptions.public.test-d.ts | 13 +- .../tests/FormApi/handleSubmit.lib.spec.ts | 1 + .../tests/FormApi/submission-handling.spec.ts | 78 +- .../tests/FormApi/validation.spec.ts | 646 +++-- .../tests/FormGroupApi/FormGroupApi.spec.ts | 262 +- .../form-core/tests/ListenerInstance.spec.ts | 61 +- .../form-core/tests/ValidatorInstance.spec.ts | 587 ++--- .../tests/devtoolsBridge.lib.spec.ts | 13 +- .../form-core/tests/listeners.lib.spec.ts | 8 +- .../form-core/tests/serverValidate.spec.ts | 283 ++- .../form-core/tests/serverValidate.test-d.ts | 6 +- .../form-core/tests/standardSchema.spec.ts | 4 +- packages/form-core/tests/utils.test.ts | 6 +- .../form-core/tests/validation-errors.test.ts | 79 +- .../tests/validation-output.test-d.ts | 10 +- .../form-core/tests/validation-output.test.ts | 7 +- .../tests/validation-pipeline.test.ts | 1435 ----------- .../validation/createErrorMap.lib.spec.ts | 2 +- .../tests/validation/parseIssues.lib.spec.ts | 5 +- .../tests/validation/state.public.test-d.ts | 4 +- .../tests/bridgeComposition.test.ts | 4 +- .../tests/fieldDetailsBridge.test.ts | 222 +- .../tests/fieldListBridge.test.ts | 9 +- .../tests/mountedFormsBridge.test.ts | 4 +- packages/react-form-start/tests/start.spec.ts | 60 +- 29 files changed, 3018 insertions(+), 3195 deletions(-) delete mode 100644 packages/form-core/tests/validation-pipeline.test.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index f831011c8..f9baad876 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -39,7 +39,10 @@ import type { import type { InternalRootFieldApi } from './RootFieldApi.lib' import type { NameSegment } from '../utils.lib' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' -import type { FieldListenerTriggers } from '../pipeline/listeners/listeners.public' +import type { + FieldListenerTriggers, + FieldListeners, +} from '../pipeline/listeners/listeners.public' import type { NotifyFieldListenerOptions, NotifyFieldValidatorOptions, @@ -79,15 +82,14 @@ import type { PipelineResult } from '../pipeline/state.lib' import type { ResolvedInternalFieldUpdateOptions } from '../types.lib' import type { FieldErrors } from '../validation/state.public' -export type AnyFieldApiOptions = FieldApiOptions< - any, - any, - any, - any, - any, - any, - any -> +export type AnyFieldApiOptions = Omit< + FieldApiOptions, any, any, any>, + 'listeners' +> & { + // error types would become something like `ValidationIssue[]` for adapter compat. + // breaks our intended internal usage, though. + listeners?: FieldListeners +} export type AnyFieldValidator = FieldValidator export interface DefaultValueCacheEntry { diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index aa6402cc2..e8d95ed05 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { formOptions } from '../../src' +import { sleep } from '../../src/pipeline/utils.lib' import { promiseWithResolvers } from '../test-utils' describe('getOrCreateFieldApi', () => { @@ -263,172 +266,6 @@ describe('InternalFieldApi - _update', () => { ) }) - it('preserves a watch when both the source and the watching field move', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { users: [{ name: 'First' }, { name: 'Second' }] }, - }) - const name0 = form._getOrCreateFieldApi({ name: 'users[0].name' }) - const name1 = form._getOrCreateFieldApi({ - name: 'users[1].name', - listeners: [ - { triggers: ['change'], watchFields: ['users[0].name'], run: listener }, - ], - }) - - name0.handleChange('before move') - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - // Before: 0 1 - // After: 1 0 - // Fields: name1 name0 - form.swapFieldValues('users', 0, 1) - name0.handleChange('before confirmation') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - value: 'Second', - fieldApi: name1, - formApi: form, - }), - ) - listener.mockClear() - - name1._update({ - listeners: [ - { triggers: ['change'], watchFields: ['users[1].name'], run: listener }, - ], - }) - name0.handleChange('after confirmation') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - value: 'Second', - fieldApi: name1, - formApi: form, - }), - ) - expect(form.state.values.users).toEqual([ - { name: 'Second' }, - { name: 'after confirmation' }, - ]) - }) - - it('retargets a watch when an update rejects following the moved field', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { users: ['first', 'second'], target: 'target' }, - }) - const target = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { triggers: ['change'], watchFields: ['users[0]'], run: listener }, - ], - }) - const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) - const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) - user0._register() - user1._register() - target._register() - - // Before: 0 1 - // After: 1 0 - // Fields: user1 user0 - form.swapFieldValues('users', 0, 1) - - user1.handleChange('first index before update') - expect(listener).not.toHaveBeenCalled() - - user0.handleChange('second index before update') - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - // Keeping users[0] rejects following user0 to users[1]. - target._update({ - listeners: [ - { triggers: ['change'], watchFields: ['users[0]'], run: listener }, - ], - }) - user0.handleChange('second index after update') - expect(listener).not.toHaveBeenCalled() - - user1.handleChange('first index after update') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - value: 'target', - fieldApi: target, - formApi: form, - }), - ) - expect(form.state.values.users).toEqual([ - 'first index after update', - 'second index after update', - ]) - }) - - it('keeps following a moved field when an update confirms its new name', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { users: ['first', 'second'], target: 'target' }, - }) - const target = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { triggers: ['change'], watchFields: ['users[0]'], run: listener }, - ], - }) - const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) - const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) - - user0.handleChange('first changed') - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ value: 'target' }), - ) - listener.mockClear() - - // Before: 0 1 - // After: 1 0 - // Fields: user1 user0 - form.swapFieldValues('users', 0, 1) - - // Follow the moved field before the next update confirms the watched name. - user1.handleChange('first index before update') - expect(listener).not.toHaveBeenCalled() - - user0.handleChange('second index before update') - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - target._update({ - listeners: [ - { triggers: ['change'], watchFields: ['users[1]'], run: listener }, - ], - }) - - user1.handleChange('first index changed') - expect(listener).not.toHaveBeenCalled() - - user0.handleChange('second index changed') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - value: 'target', - fieldApi: target, - formApi: form, - }), - ) - expect(form.state.values).toEqual({ - users: ['first index changed', 'second index changed'], - target: 'target', - }) - }) - it('retains default listeners before incoming listeners during construction and updates', () => { const defaultListener = vi.fn() const incomingListener = vi.fn() @@ -1018,6 +855,95 @@ describe('InternalFieldApi - _getIsDefaultValue', () => { }) describe('InternalFieldApi - reset', () => { + it('cancels pending mount validation before its waiting validator can start', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const waitingValidator = vi.fn(() => 'Late mount error') + let signal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['mount'], + run: (context) => { + signal = context.signal + return sleep(100).then(() => null) + }, + }, + { triggers: ['mount'], bailIfInvalid: true, run: waitingValidator }, + ], + }) + onTestFinished(field._register()) + expect(signal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + expect(waitingValidator).not.toHaveBeenCalled() + + field.reset() + + expect(signal?.aborted).toBe(true) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + + expect(waitingValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('ignores cancelled validation results and keeps new validation running after reset', async () => { + vi.useFakeTimers() + let oldSignal: AbortSignal | undefined + let currentSignal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value, signal }) => { + if (value === 'old') { + oldSignal = signal + return sleep(100).then(() => 'Stale error') + } + currentSignal = signal + return sleep(200).then(() => 'Current error') + }, + }, + ], + }) + + field.handleChange('old') + expect(oldSignal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + + field.reset() + + expect(oldSignal?.aborted).toBe(true) + expect(field.meta.isValidating).toBe(false) + + field.handleChange('new') + expect(currentSignal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + + await vi.advanceTimersByTimeAsync(100) + + expect(currentSignal?.aborted).toBe(false) + expect(field.value).toBe('new') + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + await vi.advanceTimersByTimeAsync(100) + + expect(field.errors).toEqual([{ message: 'Current error' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + it('aborts active validation, ignores its late result, and allows validation to run again', async ({ onTestFinished, }) => { @@ -1030,7 +956,7 @@ describe('InternalFieldApi - reset', () => { name: 'name', validators: [ { - triggers: [], + triggers: ['change'], run: ({ signal }: { signal: AbortSignal }) => { signal.addEventListener('abort', onAbort, { once: true }) return validator() @@ -1040,14 +966,13 @@ describe('InternalFieldApi - reset', () => { }) field._register() - const submission = form.handleSubmit() + field.handleChange('') await expect.poll(() => validator).toHaveBeenCalledOnce() expect(field.meta.isValidating).toBe(true) field.reset() expect(onAbort).toHaveBeenCalledOnce() - await expect(submission).resolves.toEqual([]) request.resolve('Late error') await request.promise @@ -1096,74 +1021,43 @@ describe('InternalFieldApi - reset', () => { }) describe('InternalFieldApi - _kill', () => { - it('does not run an unmounted field validator when a watched field is deleted and recreated', async () => { + it('cancels pending mount validation and its waiting validator when the field is deleted', async ({ + onTestFinished, + }) => { vi.useFakeTimers() - const validator = vi.fn(() => null) - const form = new InternalFormApi({ - defaultValues: { source: '', target: 'target' }, - }) - const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ - name: 'target', + const waitingValidator = vi.fn(() => 'Late mount error') + let signal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', validators: [ - { triggers: ['change'], watchFields: ['source'], run: validator }, + { + triggers: ['mount'], + run: (context) => { + signal = context.signal + return sleep(100).then(() => null) + }, + }, + { triggers: ['mount'], bailIfInvalid: true, run: waitingValidator }, ], }) - source._register() - const unregisterTarget = target._register() + onTestFinished(field._register()) + expect(signal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + expect(waitingValidator).not.toHaveBeenCalled() - source.handleChange('first') - await vi.runOnlyPendingTimersAsync() - expect(validator).toHaveBeenCalledWith( - expect.objectContaining({ value: 'target', event: 'change' }), - ) - validator.mockClear() + form.deleteField('name') - unregisterTarget() - await vi.runOnlyPendingTimersAsync() - form.deleteField('source') + expect(signal?.aborted).toBe(true) + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.isValidating).toBe(false) - form.setFieldValue('source', 'replacement') - await vi.runOnlyPendingTimersAsync() + await vi.advanceTimersByTimeAsync(100) - expect(validator).not.toHaveBeenCalled() - expect(form.getFieldValue('target')).toBe('target') + expect(waitingValidator).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() expect(form.state.isValid).toBe(true) - }) - - it('keeps a registered watcher usable after deleting its source and detaches the old dependency', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { source: '', target: 'target' }, - }) - const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { triggers: ['change'], watchFields: ['source'], run: listener }, - ], - }) - source._register() - target._register() - - source.handleChange('first') - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ value: 'target' }), - ) - listener.mockClear() - - form.deleteField('source') - form.setFieldValue('source', 'replacement') - - expect(listener).not.toHaveBeenCalled() - - target.handleChange('direct edit') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ value: 'direct edit' }), - ) - expect(form.getFieldValue('target')).toBe('direct edit') + expect(form.state.isValidating).toBe(false) }) it('ignores array edits made through a deleted field', () => { @@ -1240,14 +1134,13 @@ describe('InternalFieldApi - _kill', () => { }) field._register() - const submission = form.handleSubmit() + field.handleChange('') await expect.poll(() => validator).toHaveBeenCalledOnce() expect(field.meta.isValidating).toBe(true) form.deleteField('name') expect(onAbort).toHaveBeenCalledOnce() - await expect(submission).resolves.toEqual([]) request.resolve('Late error') await request.promise validator.mockClear() @@ -1295,31 +1188,141 @@ describe('InternalFieldApi - _kill', () => { }) describe('InternalFieldApi - _register', () => { - it('ignores registration and cleanup callbacks for a deleted field', () => { - const listener = vi.fn() + it('applies synchronous mount errors immediately', ({ onTestFinished }) => { + const validator = vi.fn(() => 'Name is required') const form = new InternalFormApi({ defaultValues: { name: '' } }) const field = form._getOrCreateFieldApi({ name: 'name', - listeners: [{ triggers: ['mount', 'unmount'], run: listener }], + validators: [{ triggers: ['mount'], run: validator }], }) - const unregister = field._register() - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - form.deleteField('name') - const unregisterAfterDeletion = field._register() - unregisterAfterDeletion() - unregister() - expect(listener).not.toHaveBeenCalled() - expect(form.getFieldMeta('name')).toBeUndefined() - }) -}) + onTestFinished(field._register()) -describe('InternalFieldApi - _unregister', () => { - it('notifies unmount listeners with the last field value', async () => { - vi.useFakeTimers() - const listener = vi.fn() + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'mount', + value: '', + fieldApi: field, + formApi: form, + }), + ) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValid).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('skips mount validation when no field validator has a mount trigger', ({ + onTestFinished, + }) => { + const validator = vi.fn(() => 'Should not run') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: ['change', 'blur'], run: validator }], + }) + + onTestFinished(field._register()) + + expect(validator).not.toHaveBeenCalled() + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValid).toBe(true) + expect(form.state.isValidating).toBe(false) + }) + + it('tracks asynchronous mount validation on the field and form until its error arrives', async ({ + onTestFinished, + }) => { + const result = promiseWithResolvers() + onTestFinished(() => result.resolve('Name is required')) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: ['mount'], run: () => result.promise }], + }) + + onTestFinished(field._register()) + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + result.resolve('Name is required') + await expect.poll(() => field.meta.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('ignores mount debounce and skips only validators whose bail condition fails', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const result = promiseWithResolvers() + onTestFinished(() => result.resolve('Deferred error')) + const pendingValidator = vi.fn(() => result.promise) + const skippedValidator = vi.fn(() => 'Should not run') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['mount'], + triggerDebounceMs: 100, + run: pendingValidator, + }, + { triggers: ['mount'], run: () => 'Immediate error' }, + { triggers: ['mount'], bailIfInvalid: true, run: skippedValidator }, + { triggers: ['mount'], run: () => 'After skipped validator' }, + ], + }) + + onTestFinished(field._register()) + + expect(pendingValidator).toHaveBeenCalledOnce() + expect(field.errors).toEqual([{ message: 'Immediate error' }]) + expect(field.meta.isValidating).toBe(true) + + result.resolve('Deferred error') + await vi.advanceTimersByTimeAsync(0) + + expect(skippedValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([ + { message: 'Deferred error' }, + { message: 'Immediate error' }, + { message: 'After skipped validator' }, + ]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('ignores registration and cleanup callbacks for a deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['mount', 'unmount'], run: listener }], + }) + const unregister = field._register() + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + form.deleteField('name') + const unregisterAfterDeletion = field._register() + unregisterAfterDeletion() + unregister() + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) +}) + +describe('InternalFieldApi - _unregister', () => { + it('notifies unmount listeners with the last field value', async () => { + vi.useFakeTimers() + const listener = vi.fn() const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) const field = form._getOrCreateFieldApi({ name: 'name', @@ -1581,107 +1584,1321 @@ describe('InternalFieldApi - filterValues', () => { const form = new InternalFormApi({ defaultValues: { items: ['a', 'b', 'c'] }, }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - const filterFieldValues = vi.spyOn(form, 'filterFieldValues') - onTestFinished(() => filterFieldValues.mockRestore()) - const thisArg = { excluded: 'b' } + const field = form._getOrCreateFieldApi({ name: 'items' }) + const filterFieldValues = vi.spyOn(form, 'filterFieldValues') + onTestFinished(() => filterFieldValues.mockRestore()) + const thisArg = { excluded: 'b' } + + field.filterValues( + function (this: { excluded: string }, value) { + return value !== this.excluded + }, + { thisArg }, + ) + + expect(filterFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'c']) + }) +}) + +type ArrayItemForm = InternalFormApi<{ items: Array<{ name: string }> }> + +describe('InternalFieldApi - validation after array changes', () => { + it.for([ + { + operation: 'insert', + mutate: (form: ArrayItemForm) => + form.insertFieldValue('items', 0, { name: 'new' }), + name: 'items[2].name', + }, + { + operation: 'remove', + mutate: (form: ArrayItemForm) => form.removeFieldValue('items', 0), + name: 'items[0].name', + }, + { + operation: 'swap', + mutate: (form: ArrayItemForm) => form.swapFieldValues('items', 0, 1), + name: 'items[0].name', + }, + { + operation: 'move forward', + mutate: (form: ArrayItemForm) => form.moveFieldValue('items', 0, 2), + name: 'items[0].name', + }, + { + operation: 'move backward', + mutate: (form: ArrayItemForm) => form.moveFieldValue('items', 2, 0), + name: 'items[2].name', + }, + { + operation: 'filter', + mutate: (form: ArrayItemForm) => + form.filterFieldValues('items', (value) => value.name !== 'first'), + name: 'items[0].name', + }, + { + operation: 'swap without validation', + mutate: (form: ArrayItemForm) => + form.swapFieldValues('items', 0, 1, { causeValidation: false }), + name: 'items[0].name', + }, + ])( + "$operation cancels the moved field's pending validation and preserves its errors", + async ({ mutate, name }) => { + vi.useFakeTimers() + let shouldWait = false + let signal: AbortSignal | undefined + const validator = vi.fn(() => + shouldWait ? sleep(100).then(() => 'Late error') : 'Stored error', + ) + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'first' }, { name: 'moved' }, { name: 'third' }], + }, + }) + const field = form._getOrCreateFieldApi({ + name: 'items[1].name', + validators: [ + { + triggers: ['blur', 'change'], + run: (context) => { + signal = context.signal + return validator() + }, + }, + ], + }) + field.handleBlur() + const previousMeta = field.meta + expect(field.errors).toEqual([{ message: 'Stored error' }]) + + shouldWait = true + field.handleBlur() + expect(signal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + mutate(form) + + expect(signal?.aborted).toBe(true) + expect(field.name).toBe(name) + expect(field.value).toBe('moved') + expect(form.getFieldValue(name)).toBe('moved') + expect(form.getFieldMeta(name)).toMatchObject({ + isValidating: false, + isSelfValidating: false, + isSelfTouched: previousMeta.isSelfTouched, + isSelfDirty: previousMeta.isSelfDirty, + isBlurred: previousMeta.isBlurred, + errors: [{ message: 'Stored error' }], + }) + expect(form.state.isValidating).toBe(false) + expect(validator).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(100) + + expect(field.errors).toEqual([{ message: 'Stored error' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }, + ) +}) + +describe('InternalFieldApi - error visibility', () => { + it('keeps errors hidden after blur when the policy requires a submit attempt', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + errorVisibility: ({ state }) => state.submissionAttempts > 0, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field.handleBlur() + + await form.validate('change') + + expect(field.errors).toEqual([]) + expect(field.meta.original.errors).toEqual([ + { message: 'Name is required' }, + ]) + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('provides current values and interaction state to the error visibility policy', ({ + onTestFinished, + }) => { + const options = formOptions({ + defaultValues: { name: '' }, + errorVisibility: ({ state, fieldState }) => + state.values.name === 'Alice' && + fieldState.value === 'Alice' && + fieldState.meta.isTouched && + fieldState.meta.isDirty, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name error' } }), + }, + ], + }) + const policy = vi.spyOn(options, 'errorVisibility') + onTestFinished(() => policy.mockRestore()) + const form = new InternalFormApi(options) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(field.errors).toEqual([{ message: 'Name error' }]) + expect(policy).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ values: { name: 'Alice' } }), + fieldState: expect.objectContaining({ + value: 'Alice', + meta: expect.objectContaining({ + isTouched: true, + isSelfTouched: true, + isDirty: true, + isSelfDirty: true, + isPristine: false, + }), + }), + }), + ) + const fieldMeta = policy.mock.lastCall?.[0].fieldState.meta + expect(fieldMeta).not.toHaveProperty('errors') + expect(fieldMeta).not.toHaveProperty('isValid') + }) + + it('provides form errors and validity to the error visibility policy', async ({ + onTestFinished, + }) => { + const options = formOptions({ + defaultValues: { name: '' }, + errorVisibility: ({ state }) => + state.errors[0]?.message === 'Form error' && + !state.isValid && + state.isInvalid && + !state.canSubmit, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ + form: 'Form error', + fields: { name: 'Name error' }, + }), + }, + ], + }) + const policy = vi.spyOn(options, 'errorVisibility') + onTestFinished(() => policy.mockRestore()) + const form = new InternalFormApi(options) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.validate('change') + + expect(field.errors).toEqual([{ message: 'Name error' }]) + expect(policy).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + errors: [{ message: 'Form error' }], + isValid: false, + isInvalid: true, + canSubmit: false, + }), + }), + ) + }) + + it('hides untouched field errors without discarding validation failures', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', age: 0 }, + errorVisibility: ({ fieldState }) => fieldState.meta.isTouched, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ + fields: { name: 'Name is required', age: 'Age is required' }, + }), + }, + ], + }) + const name = form._getOrCreateFieldApi({ name: 'name' }) + const age = form._getOrCreateFieldApi({ name: 'age' }) + name.handleBlur() + + await form.validate('change') + + expect(name.errors).toEqual([{ message: 'Name is required' }]) + expect(name.meta.isValid).toBe(false) + expect(name.meta.original).toMatchObject({ + errors: [{ message: 'Name is required' }], + isValid: false, + }) + expect(age.errors).toEqual([]) + expect(age.meta.isValid).toBe(true) + expect(age.meta.original).toMatchObject({ + errors: [{ message: 'Age is required' }], + isValid: false, + }) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + }) + + it('uses the field error visibility policy in place of the form policy', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + errorVisibility: () => false, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), + }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + errorVisibility: () => true, + }) + + await form.validate('change') + + expect(field.meta.isTouched).toBe(false) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(field.meta.isValid).toBe(false) + expect(field.meta.original).toMatchObject({ + errors: [{ message: 'Name is required' }], + isValid: false, + }) + }) + + it('reveals errors when a field is blurred or the form is submitted', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + errorVisibility: ({ state, fieldState }) => + fieldState.meta.isBlurred || state.submissionAttempts > 0, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ + fields: { name: 'Name is required', email: 'Email is required' }, + }), + }, + ], + }) + const name = form._getOrCreateFieldApi({ name: 'name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.validate('change') + expect(name.errors).toEqual([]) + expect(email.errors).toEqual([]) + + name.handleBlur() + + expect(name.errors).toEqual([{ message: 'Name is required' }]) + expect(email.errors).toEqual([]) + + await form.handleSubmit() + + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.canSubmit).toBe(false) + }) +}) + +describe('InternalFieldApi - validation state', () => { + it('marks the parent as validating while a subfield validator is pending', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + validators: [ + { triggers: ['blur'], run: () => sleep(100).then(() => null) }, + ], + }) + + child.handleBlur() + + expect(child.meta).toMatchObject({ + isValidating: true, + isSelfValidating: true, + subfields: { isSomeValidating: false }, + }) + expect(parent.meta).toMatchObject({ + isValidating: true, + isSelfValidating: false, + subfields: { isSomeValidating: true }, + }) + expect(form.state.isValidating).toBe(true) + + await vi.advanceTimersByTimeAsync(100) + + expect(child.meta.isValidating).toBe(false) + expect(parent.meta).toMatchObject({ + isValidating: false, + isSelfValidating: false, + subfields: { isSomeValidating: false }, + }) + expect(form.state.isValidating).toBe(false) + }) + + it('distinguishes a parent with no errors of its own from its invalid subfield', () => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + validators: [{ triggers: ['blur'], run: () => 'Name is required' }], + }) + + child.handleBlur() + + expect(child.meta).toMatchObject({ + errors: [{ message: 'Name is required' }], + isSelfValid: false, + isValid: false, + isInvalid: true, + subfields: { isEveryValid: true, isAnyInvalid: false }, + }) + expect(parent.meta).toMatchObject({ + errors: [], + isSelfValid: true, + isValid: false, + isInvalid: true, + subfields: { isEveryValid: false, isAnyInvalid: true }, + }) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + }) + + it('returns the parent and form to valid only after every subfield error clears', () => { + let reportErrors = true + const form = new InternalFormApi({ + defaultValues: { user: { name: '', email: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const name = form._getOrCreateFieldApi({ + name: 'user.name', + validators: [ + { triggers: ['blur'], run: () => reportErrors && 'Name is required' }, + ], + }) + const email = form._getOrCreateFieldApi({ + name: 'user.email', + validators: [ + { triggers: ['blur'], run: () => reportErrors && 'Email is required' }, + ], + }) + name.handleBlur() + email.handleBlur() + expect(parent.meta.isValid).toBe(false) + + reportErrors = false + name.handleBlur() + + expect(name.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(parent.meta.subfields.isAnyInvalid).toBe(true) + expect(form.state.isValid).toBe(false) + + email.handleBlur() + + expect(parent.meta).toMatchObject({ + isSelfValid: true, + isValid: true, + isInvalid: false, + subfields: { isEveryValid: true, isAnyInvalid: false }, + }) + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + expect(parent.value).toEqual({ name: '', email: '' }) + }) + + it('keeps the parent invalid while either its own validation or a subfield reports an error', () => { + let parentInvalid = true + let childInvalid = true + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'user', + validators: [ + { triggers: ['blur'], run: () => parentInvalid && 'Parent error' }, + ], + }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + validators: [ + { triggers: ['blur'], run: () => childInvalid && 'Child error' }, + ], + }) + child.handleBlur() + expect(parent.meta).toMatchObject({ + isSelfValid: false, + isValid: false, + subfields: { isAnyInvalid: true }, + }) + + parentInvalid = false + parent.handleBlur() + + expect(parent.meta).toMatchObject({ + errors: [], + isSelfValid: true, + isValid: false, + subfields: { isAnyInvalid: true }, + }) + expect(child.errors).toEqual([{ message: 'Child error' }]) + + parentInvalid = true + parent.handleBlur() + childInvalid = false + child.handleBlur() + + expect(child.errors).toEqual([]) + expect(parent.meta).toMatchObject({ + errors: [{ message: 'Parent error' }], + isSelfValid: false, + isValid: false, + subfields: { isEveryValid: true, isAnyInvalid: false }, + }) + expect(form.state.isValid).toBe(false) + + parentInvalid = false + parent.handleBlur() + + expect(parent.meta.isValid).toBe(true) + expect(form.state.isValid).toBe(true) + }) +}) + +describe('InternalFieldApi - validator results', () => { + it('preserves schema issues returned by a field validator and clears them when the value becomes valid', () => { + const schema = z.string().min(3, 'Name is too short') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }) => { + const result = schema.safeParse(value) + return result.success ? null : result.error.issues + }, + }, + ], + }) + + field.handleChange('a') + + expect(field.errors).toEqual([ + expect.objectContaining({ + message: 'Name is too short', + code: 'too_small', + path: [], + }), + ]) + expect(form.state.canSubmit).toBe(false) + + field.handleChange('Alice') + + expect(field.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + it('ignores outputs from field validators in form submit', async () => { + const onSubmit = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit, + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: z.string().transform((value) => value.toUpperCase()), + }, + ], + }) + + field.handleChange('Alice') + + expect(field.value).toBe('Alice') + expect(form.state.values).toEqual({ name: 'Alice' }) + expect(field.errors).toEqual([]) + + await form.handleSubmit() + + expect(field.value).toBe('Alice') + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'Alice' }, + validatorOutputs: [], + }), + ) + }) + + it('combines errors from field and form validators and clears each source independently', () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['blur'], + run: ({ value, createErrorMap }) => + createErrorMap({ + fields: { name: value.name.length < 5 ? 'Form error' : null }, + }), + }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }) => (value.length < 3 ? 'Field error' : null), + }, + ], + }) + + field.handleChange('a') + field.handleBlur() + + expect(field.errors).toEqual([ + { message: 'Field error' }, + { message: 'Form error' }, + ]) + + field.handleChange('Ali') + + expect(field.errors).toEqual([{ message: 'Form error' }]) + expect(form.state.isValid).toBe(false) + + field.handleChange('Alice') + field.handleBlur() + + expect(field.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + }) +}) + +describe('InternalFieldApi - handleChange', () => { + it('updates the value without running validators when causeValidation is false', () => { + const formValidator = vi.fn(() => 'Form error') + const parentValidator = vi.fn(() => 'Parent error') + const fieldValidator = vi.fn(() => 'Field error') + const watchedValidator = vi.fn(() => 'Watched error') + const form = new InternalFormApi({ + defaultValues: { user: { name: '' }, confirmation: '' }, + validators: [{ triggers: ['change'], run: formValidator }], + }) + const parent = form._getOrCreateFieldApi({ + name: 'user', + validators: [{ triggers: ['change'], run: parentValidator }], + }) + const field = form._getOrCreateFieldApi({ + name: 'user.name', + validators: [{ triggers: ['change'], run: fieldValidator }], + }) + const confirmation = form._getOrCreateFieldApi({ + name: 'confirmation', + validators: [ + { + triggers: ['change'], + watchFields: ['user.name'], + run: watchedValidator, + }, + ], + }) + + field.handleChange('Alice', { causeValidation: false }) + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ isTouched: true, isDirty: true }) + expect(fieldValidator).not.toHaveBeenCalled() + expect(parentValidator).not.toHaveBeenCalled() + expect(watchedValidator).not.toHaveBeenCalled() + expect(formValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([]) + expect(parent.errors).toEqual([]) + expect(confirmation.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + }) + + it('uses the current value and touched state for conditional change validation', () => { + const validator = vi.fn(() => 'Change error') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, fieldApi, formApi }) => + fieldApi.form === formApi && + value === 'Alice' && + fieldApi.meta.isTouched, + }, + ], + run: validator, + }, + ], + }) + + field.handleChange('Alice', { markAsTouched: false }) + + expect(validator).not.toHaveBeenCalled() + expect(field.errors).toEqual([]) + + field.handleChange('Alice') + + expect(validator).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + event: 'change', + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + expect(field.errors).toEqual([{ message: 'Change error' }]) + }) + + it('cancels an obsolete change run before its waiting validator can use the new value', async () => { + vi.useFakeTimers() + const waitingValidator = vi.fn(() => 'Current error') + let oldSignal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value, signal }) => { + if (value === 'old') { + oldSignal = signal + return sleep(100).then(() => null) + } + return null + }, + }, + { triggers: ['change'], bailIfInvalid: true, run: waitingValidator }, + ], + }) + + field.handleChange('old') + expect(oldSignal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + expect(waitingValidator).not.toHaveBeenCalled() + + field.handleChange('new') + + expect(oldSignal?.aborted).toBe(true) + expect(waitingValidator).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ event: 'change', value: 'new' }), + ) + expect(field.errors).toEqual([{ message: 'Current error' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + + expect(waitingValidator).toHaveBeenCalledOnce() + expect(field.value).toBe('new') + expect(field.errors).toEqual([{ message: 'Current error' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('applies synchronous function and schema errors before change returns', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { triggers: ['change'], run: () => 'Function error' }, + { triggers: ['change'], run: z.string().min(3, 'Schema error') }, + ], + }) + + field.handleChange('a') + + expect(field.errors.map(({ message }) => message)).toEqual([ + 'Function error', + 'Schema error', + ]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('displays asynchronous errors as they arrive in validator order', async ({ + onTestFinished, + }) => { + const first = promiseWithResolvers() + const second = promiseWithResolvers() + onTestFinished(() => { + first.resolve('First error') + second.resolve('Second error') + }) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { triggers: ['change'], run: () => first.promise }, + { triggers: ['change'], run: () => second.promise }, + ], + }) + + field.handleChange('value') + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(true) + expect(field.meta.isSelfValidating).toBe(true) + expect(field.meta.subfields.isSomeValidating).toBe(false) + expect(form.state.isValidating).toBe(true) + + second.resolve('Second error') + await expect.poll(() => field.errors).toEqual([{ message: 'Second error' }]) + + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + first.resolve('First error') + await expect.poll(() => field.meta.isValidating).toBe(false) + + expect(field.errors).toEqual([ + { message: 'First error' }, + { message: 'Second error' }, + ]) + expect(field.meta.isSelfValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it.for([true, false])( + 'cancels pending mount validation when the value changes, regardless of causeValidation (%s)', + async (causeValidation, { onTestFinished }) => { + vi.useFakeTimers() + const pending = promiseWithResolvers() + onTestFinished(() => pending.resolve('Late mount error')) + const waitingValidator = vi.fn(() => 'Should not run') + let signal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['mount'], + run: (context) => { + signal = context.signal + return pending.promise + }, + }, + { triggers: ['mount'], bailIfInvalid: true, run: waitingValidator }, + ], + }) + onTestFinished(field._register()) + expect(signal?.aborted).toBe(false) + expect(field.meta.isValidating).toBe(true) + + field.handleChange('new value', { causeValidation }) + + expect(field.value).toBe('new value') + expect(signal?.aborted).toBe(true) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + + pending.resolve('Late mount error') + await vi.advanceTimersByTimeAsync(0) + + expect(waitingValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }, + ) + + it('sets the value through setFieldValue and marks the field and form touched and dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice') + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: true, + }) + }) + + it('updates the value through setFieldValue with an updater function', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { count: 1 } }) + const field = form._getOrCreateFieldApi({ name: 'count' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange((count: number) => count + 1) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe(2) + }) + + it('updates the value and touched state while keeping the field and form pristine when markAsDirty is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsDirty: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: false, + isPristine: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: false, + isPristine: true, + }) + }) + + it('updates the value and dirty state while keeping the field and form untouched when markAsTouched is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsTouched: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: true, + }) + }) +}) + +describe('InternalFieldApi - validator failures', () => { + it.for([ + { event: 'mount', failure: 'synchronous' }, + { event: 'mount', failure: 'asynchronous' }, + { event: 'change', failure: 'synchronous' }, + { event: 'change', failure: 'asynchronous' }, + { event: 'blur', failure: 'synchronous' }, + { event: 'blur', failure: 'asynchronous' }, + ] as const)( + 'logs a $failure $event failure once and preserves the previously applied error', + async ({ event, failure }, { onTestFinished }) => { + const error = new Error('Field validation failed') + const pending = promiseWithResolvers() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => log.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [event], + run: ({ event: selectedEvent }) => { + if (selectedEvent === 'submit') return 'Stored error' + if (failure === 'asynchronous') return pending.promise + throw error + }, + }, + ], + }) + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Stored error' }]) + + if (event === 'mount') onTestFinished(field._register()) + else if (event === 'change') field.handleChange('new') + else field.handleBlur() + + if (failure === 'asynchronous') { + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + pending.reject(error) + } + + await expect + .poll(() => log) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ errors: [error] }), + ) + expect(field.errors).toEqual([{ message: 'Stored error' }]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }, + ) +}) + +describe('InternalFieldApi - handleBlur', () => { + it('runs blur validation when its predicate sees the updated blurred state', () => { + const validator = vi.fn(() => 'Blur error') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ fieldApi }) => fieldApi.meta.isBlurred, + }, + { + trigger: 'blur', + when: ({ value, fieldApi, formApi }) => + fieldApi.form === formApi && + value === 'Alice' && + fieldApi.meta.isBlurred, + }, + ], + run: validator, + }, + ], + }) + field.handleChange('Alice') + expect(validator).not.toHaveBeenCalled() + + field.handleBlur() + + expect(validator).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + event: 'blur', + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + expect(field.errors).toEqual([{ message: 'Blur error' }]) + }) + + it('replaces matching pending validation while preserving change-only validation', async ({ + onTestFinished, + }) => { + const oldChange = promiseWithResolvers() + const blur = promiseWithResolvers() + const changeOnly = promiseWithResolvers() + onTestFinished(() => { + oldChange.resolve('Stale change error') + blur.resolve('Blur error') + changeOnly.resolve('Change-only error') + }) + let oldChangeSignal: AbortSignal | undefined + let changeOnlySignal: AbortSignal | undefined + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change', 'blur'], + run: ({ event, signal }) => { + if (event === 'change') { + oldChangeSignal = signal + return oldChange.promise + } + return blur.promise + }, + }, + { + triggers: ['change'], + run: ({ signal }) => { + changeOnlySignal = signal + return changeOnly.promise + }, + }, + ], + }) + + field.handleChange('value') + expect(oldChangeSignal?.aborted).toBe(false) + expect(changeOnlySignal?.aborted).toBe(false) + + field.handleBlur() + + expect(oldChangeSignal?.aborted).toBe(true) + expect(changeOnlySignal?.aborted).toBe(false) + + blur.resolve('Blur error') + await expect.poll(() => field.errors).toEqual([{ message: 'Blur error' }]) + + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + oldChange.resolve('Stale change error') + changeOnly.resolve('Change-only error') + await expect.poll(() => field.meta.isValidating).toBe(false) + + expect(field.errors).toEqual([ + { message: 'Blur error' }, + { message: 'Change-only error' }, + ]) + expect(form.state.isValidating).toBe(false) + }) + + it('preserves mount and submit errors whose validators do not run on blur', async ({ + onTestFinished, + }) => { + const mountValidator = vi.fn(() => 'Mount error') + const submitValidator = vi.fn(() => 'Submit error') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { triggers: ['mount'], skipOnSubmit: true, run: mountValidator }, + { triggers: [], run: submitValidator }, + ], + }) + onTestFinished(field._register()) + expect(field.errors).toEqual([{ message: 'Mount error' }]) + + await form.handleSubmit() + expect(field.errors).toEqual([ + { message: 'Mount error' }, + { message: 'Submit error' }, + ]) + mountValidator.mockClear() + submitValidator.mockClear() + + field.handleBlur() + + expect(mountValidator).not.toHaveBeenCalled() + expect(submitValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([ + { message: 'Mount error' }, + { message: 'Submit error' }, + ]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) +}) + +describe('InternalFieldApi - _notifyValidator', () => { + it('validates the watching field with its own value and context when the source changes', () => { + const validator = vi.fn(() => 'Target is invalid') + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + + source.handleChange('changed source') + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'change', + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(target.errors).toEqual([{ message: 'Target is invalid' }]) + expect(source.errors).toEqual([]) + expect(target.value).toBe('target') + expect(form.state.isValid).toBe(false) + }) + + it('runs only validators watching the source with a matching change or blur trigger', () => { + const changeValidator = vi.fn(() => 'Change error') + const blurValidator = vi.fn(() => 'Blur error') + const unrelatedValidator = vi.fn(() => 'Unrelated error') + const unrelatedValidator2 = vi.fn(() => 'Direct error') + const form = new InternalFormApi({ + defaultValues: { source: '', other: '', target: '' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: changeValidator }, + { triggers: ['blur'], watchFields: ['source'], run: blurValidator }, + { + triggers: ['change', 'blur'], + watchFields: ['other'], + run: unrelatedValidator, + }, + { triggers: ['change', 'blur'], run: unrelatedValidator2 }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + + source.handleChange('changed source') + + expect(changeValidator).toHaveBeenCalledOnce() + expect(blurValidator).not.toHaveBeenCalled() + expect(unrelatedValidator).not.toHaveBeenCalled() + expect(unrelatedValidator2).not.toHaveBeenCalled() + expect(target.errors).toEqual([{ message: 'Change error' }]) + + source.handleBlur() + + expect(changeValidator).toHaveBeenCalledOnce() + expect(blurValidator).toHaveBeenCalledOnce() + expect(unrelatedValidator).not.toHaveBeenCalled() + expect(unrelatedValidator2).not.toHaveBeenCalled() + expect(target.errors).toEqual([ + { message: 'Change error' }, + { message: 'Blur error' }, + ]) + }) + + it('updates validation errors when either of multiple watched fields changes', () => { + const form = new InternalFormApi({ + defaultValues: { source: '', other: '', target: '' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { + triggers: ['change'], + watchFields: ['source', 'other'], + run: () => + form.state.values.source === form.state.values.other + ? undefined + : 'Values must match', + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) - field.filterValues( - function (this: { excluded: string }, value) { - return value !== this.excluded - }, - { thisArg }, - ) + source.handleChange('matching value') - expect(filterFieldValues).toHaveBeenCalledOnce() - expect(field.value).toEqual(['a', 'c']) - }) -}) + expect(target.errors).toEqual([{ message: 'Values must match' }]) -describe('InternalFieldApi - handleChange', () => { - it('sets the value through setFieldValue and marks the field and form touched and dirty', ({ - onTestFinished, - }) => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const setFieldValue = vi.spyOn(form, 'setFieldValue') - onTestFinished(() => setFieldValue.mockRestore()) + other.handleChange('matching value') - field.handleChange('Alice') + expect(target.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + }) - expect(setFieldValue).toHaveBeenCalledOnce() - expect(field.value).toBe('Alice') - expect(field.meta).toMatchObject({ - isTouched: true, - isDirty: true, + it('replaces selected watched errors in validator order while preserving unselected errors', () => { + const otherValidator = vi.fn(() => 'Other error') + const form = new InternalFormApi({ + defaultValues: { source: '', other: '', target: '' }, }) - expect(form.state).toMatchObject({ - values: { name: 'Alice' }, - isTouched: true, - isDirty: true, + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['other'], run: otherValidator }, + { + triggers: ['change'], + watchFields: ['source'], + run: () => + form.state.values.source !== 'valid' && + `Source: ${form.state.values.source}`, + }, + ], }) - }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) - it('updates the value through setFieldValue with an updater function', ({ - onTestFinished, - }) => { - const form = new InternalFormApi({ defaultValues: { count: 1 } }) - const field = form._getOrCreateFieldApi({ name: 'count' }) - const setFieldValue = vi.spyOn(form, 'setFieldValue') - onTestFinished(() => setFieldValue.mockRestore()) + source.handleChange('first') + expect(otherValidator).not.toHaveBeenCalled() + expect(target.errors).toEqual([{ message: 'Source: first' }]) - field.handleChange((count: number) => count + 1) + other.handleChange('other value') + expect(target.errors).toEqual([ + { message: 'Other error' }, + { message: 'Source: first' }, + ]) + otherValidator.mockClear() - expect(setFieldValue).toHaveBeenCalledOnce() - expect(field.value).toBe(2) + source.handleChange('updated') + expect(target.errors).toEqual([ + { message: 'Other error' }, + { message: 'Source: updated' }, + ]) + + source.handleChange('valid') + expect(target.errors).toEqual([{ message: 'Other error' }]) + expect(otherValidator).not.toHaveBeenCalled() }) - it('updates the value and touched state while keeping the field and form pristine when markAsDirty is false', ({ + it('warns and stops cyclical validator watches while retaining each field error', ({ onTestFinished, }) => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const setFieldValue = vi.spyOn(form, 'setFieldValue') - onTestFinished(() => setFieldValue.mockRestore()) - - field.handleChange('Alice', { markAsDirty: false }) - - expect(setFieldValue).toHaveBeenCalledOnce() - expect(field.value).toBe('Alice') - expect(field.meta).toMatchObject({ - isTouched: true, - isDirty: false, - isPristine: true, + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const firstValidator = vi.fn(() => 'First error') + const secondValidator = vi.fn(() => 'Second error') + const form = new InternalFormApi({ + defaultValues: { first: '', second: '' }, }) - expect(form.state).toMatchObject({ - values: { name: 'Alice' }, - isTouched: true, - isDirty: false, - isPristine: true, + const first = form._getOrCreateFieldApi({ + name: 'first', + validators: [ + { + triggers: ['change'], + watchFields: ['second'], + run: firstValidator, + }, + ], + }) + const second = form._getOrCreateFieldApi({ + name: 'second', + validators: [ + { + triggers: ['change'], + watchFields: ['first'], + run: secondValidator, + }, + ], }) - }) - - it('updates the value and dirty state while keeping the field and form untouched when markAsTouched is false', ({ - onTestFinished, - }) => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const setFieldValue = vi.spyOn(form, 'setFieldValue') - onTestFinished(() => setFieldValue.mockRestore()) - field.handleChange('Alice', { markAsTouched: false }) + first.handleChange('changed first') - expect(setFieldValue).toHaveBeenCalledOnce() - expect(field.value).toBe('Alice') - expect(field.meta).toMatchObject({ - isTouched: false, - isDirty: true, - }) - expect(form.state).toMatchObject({ - values: { name: 'Alice' }, - isTouched: false, - isDirty: true, - }) + expect(firstValidator).toHaveBeenCalledOnce() + expect(secondValidator).toHaveBeenCalledOnce() + expect(first.errors).toEqual([{ message: 'First error' }]) + expect(second.errors).toEqual([{ message: 'Second error' }]) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('cyclical validator cycle detected'), + ) }) }) @@ -2112,4 +3329,231 @@ describe('InternalFieldApi - _clearEventErrors', () => { expect(field.errors).toEqual([]) expect(validator).not.toHaveBeenCalled() }) + + it('retains submit errors until debounced asynchronous change validation finishes', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const pending = promiseWithResolvers() + onTestFinished(() => pending.resolve(null)) + const changeValidator = vi.fn(() => pending.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: ({ event }) => + event === 'submit' ? 'Submit error' : changeValidator(), + }, + ], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit error' }]) + + field.handleChange('new value') + + expect(changeValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([{ message: 'Submit error' }]) + + await vi.advanceTimersByTimeAsync(100) + + expect(changeValidator).toHaveBeenCalledOnce() + expect(field.errors).toEqual([{ message: 'Submit error' }]) + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + pending.resolve(null) + await vi.advanceTimersByTimeAsync(0) + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('retains a waiting validator error until an earlier error causes it to be skipped', async ({ + onTestFinished, + }) => { + const earlier = promiseWithResolvers() + onTestFinished(() => earlier.resolve('Earlier error')) + const skippedValidator = vi.fn(() => 'Stored error') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ event }) => (event === 'submit' ? null : earlier.promise), + }, + { triggers: ['change'], bailIfInvalid: true, run: skippedValidator }, + { + triggers: ['change'], + run: ({ event }) => (event === 'submit' ? null : 'Later error'), + }, + ], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Stored error' }]) + skippedValidator.mockClear() + + field.handleChange('new value') + + expect(field.errors).toEqual([{ message: 'Stored error' }]) + expect(field.meta.isValidating).toBe(true) + + earlier.resolve('Earlier error') + await expect.poll(() => field.meta.isValidating).toBe(false) + + expect(skippedValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([ + { message: 'Earlier error' }, + { message: 'Later error' }, + ]) + expect(form.state.isValidating).toBe(false) + }) + + it('retains a waiting validator error until its replacement validation finishes', async ({ + onTestFinished, + }) => { + const earlier = promiseWithResolvers() + const replacement = promiseWithResolvers() + onTestFinished(() => { + earlier.resolve(null) + replacement.resolve(null) + }) + const replacementValidator = vi.fn(() => replacement.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ event }) => (event === 'submit' ? null : earlier.promise), + }, + { + triggers: ['change'], + bailIfInvalid: true, + run: ({ event }) => + event === 'submit' ? 'Stored error' : replacementValidator(), + }, + ], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Stored error' }]) + + field.handleChange('new value') + expect(replacementValidator).not.toHaveBeenCalled() + expect(field.errors).toEqual([{ message: 'Stored error' }]) + + earlier.resolve(null) + await expect.poll(() => replacementValidator).toHaveBeenCalledOnce() + + expect(field.errors).toEqual([{ message: 'Stored error' }]) + expect(field.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + replacement.resolve(null) + await expect.poll(() => field.meta.isValidating).toBe(false) + + expect(field.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + expect(form.state.isValidating).toBe(false) + }) + + it('clears submit errors on the changed field and its parents while preserving sibling errors', async () => { + const form = new InternalFormApi({ + defaultValues: { parent: { child: '', sibling: '' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'parent', + validators: [{ triggers: [], run: () => 'Parent error' }], + }) + const child = form._getOrCreateFieldApi({ + name: 'parent.child', + validators: [{ triggers: [], run: () => 'Child error' }], + }) + const sibling = form._getOrCreateFieldApi({ + name: 'parent.sibling', + validators: [{ triggers: [], run: () => 'Sibling error' }], + }) + + await form.handleSubmit() + expect(parent.errors).toEqual([{ message: 'Parent error' }]) + expect(child.errors).toEqual([{ message: 'Child error' }]) + expect(sibling.errors).toEqual([{ message: 'Sibling error' }]) + + child.handleChange('new value', { causeValidation: false }) + + expect(parent.errors).toEqual([]) + expect(child.errors).toEqual([]) + expect(sibling.errors).toEqual([{ message: 'Sibling error' }]) + expect(form.state.isValid).toBe(false) + }) + + it('preserves subfield errors when a parent value is replaced until the subfield is edited', async () => { + // This one could be RFC-worthy. I'm not sure if there's a solid argument for this one. + // Reach out and let me know ~Luca + const form = new InternalFormApi({ + defaultValues: { parent: { child: '' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'parent', + validators: [{ triggers: [], run: () => 'Parent error' }], + }) + const child = form._getOrCreateFieldApi({ + name: 'parent.child', + validators: [{ triggers: [], run: () => 'Child error' }], + }) + + await form.handleSubmit() + expect(parent.errors).toEqual([{ message: 'Parent error' }]) + expect(child.errors).toEqual([{ message: 'Child error' }]) + + parent.handleChange({ child: 'replacement' }, { causeValidation: false }) + + expect(parent.errors).toEqual([]) + expect(child.value).toBe('replacement') + expect(child.errors).toEqual([{ message: 'Child error' }]) + expect(form.state.isValid).toBe(false) + + child.handleChange('direct edit', { causeValidation: false }) + + expect(child.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + }) + + it("clears only the watching validator's submit error when its source changes", async () => { + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { + triggers: ['change'], + watchFields: ['source'], + run: ({ event }) => + event === 'submit' ? 'Watched submit error' : null, + }, + { triggers: [], run: () => 'Unwatched submit error' }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + + await form.handleSubmit() + expect(target.errors).toEqual([ + { message: 'Watched submit error' }, + { message: 'Unwatched submit error' }, + ]) + + source.handleChange('changed source') + + expect(target.value).toBe('target') + expect(target.errors).toEqual([{ message: 'Unwatched submit error' }]) + expect(form.state.isValid).toBe(false) + }) }) diff --git a/packages/form-core/tests/FieldApi/meta.spec.ts b/packages/form-core/tests/FieldApi/meta.spec.ts index 462af691d..814eb9b42 100644 --- a/packages/form-core/tests/FieldApi/meta.spec.ts +++ b/packages/form-core/tests/FieldApi/meta.spec.ts @@ -1,32 +1,6 @@ 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' -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(() => { @@ -34,82 +8,6 @@ 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' }) @@ -150,122 +48,6 @@ describe('field - meta', () => { expect(field.meta.isSelfTouched).toBe(false) expect(field.meta.subfields.isSomeTouched).toBe(false) }) - - it('tracks self validation state while a field validator is pending', async () => { - vi.useFakeTimers() - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [ - { - triggers: ['change'], - run: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)) - return null - }, - }, - ], - }) - - const validationPromise = field._runFieldValidation('change') - - expect(field.meta.isValidating).toBe(true) - expect(field.meta.isSelfValidating).toBe(true) - expect(field.meta.subfields.isSomeValidating).toBe(false) - - await vi.runAllTimersAsync() - await validationPromise - - expect(field.meta.isValidating).toBe(false) - expect(field.meta.isSelfValidating).toBe(false) - vi.useRealTimers() - }) - - it('aggregates validation state from subfields', async () => { - vi.useFakeTimers() - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ - name: 'a.b', - validators: [ - { - triggers: ['change'], - run: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)) - return null - }, - }, - ], - }) - - const validationPromise = child._runFieldValidation('change') - - vi.waitFor(() => { - expect(child.meta.isValidating).toBe(true) - expect(parent.meta.isValidating).toBe(true) - expect(parent.meta.isSelfValidating).toBe(false) - expect(parent.meta.subfields.isSomeValidating).toBe(true) - }) - - await vi.runAllTimersAsync() - await validationPromise - - expect(parent.meta.isValidating).toBe(false) - expect(parent.meta.subfields.isSomeValidating).toBe(false) - vi.useRealTimers() - }) - - it('separates self validity from subfield validity', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - setFieldValidatorErrors(child, [{ message: 'Required' }]) - - vi.waitFor(() => { - expect(child.meta.isSelfValid).toBe(false) - expect(child.meta.subfields.isEveryValid).toBe(true) - expect(child.meta.subfields.isAnyInvalid).toBe(false) - expect(child.meta.isValid).toBe(false) - - expect(parent.meta.isSelfValid).toBe(true) - expect(parent.meta.subfields.isEveryValid).toBe(false) - expect(parent.meta.subfields.isAnyInvalid).toBe(true) - expect(parent.meta.isValid).toBe(false) - expect(parent.meta.isInvalid).toBe(true) - }) - }) - - it('updates child error contribution counts when only error state changes', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - setFieldValidatorErrors(child, [{ message: 'Required' }]) - - expect(parent._getBaseMeta().childContributionCounts.error).toBe(1) - - setFieldValidatorErrors(child, []) - - expect(parent._getBaseMeta().childContributionCounts.error).toBe(0) - }) - - it('keeps aggregate validity invalid when self and subfields both have errors', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - setFieldValidatorErrors(parent, [{ message: 'Parent error' }]) - setFieldValidatorErrors(child, [{ message: 'Child error' }]) - - vi.waitFor(() => { - expect(parent.meta.isSelfValid).toBe(false) - expect(parent.meta.subfields.isEveryValid).toBe(false) - expect(parent.meta.subfields.isAnyInvalid).toBe(true) - expect(parent.meta.isValid).toBe(false) - expect(parent.meta.isInvalid).toBe(true) - }) - }) }) describe('dirty/touched propagation', () => { diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 816401a26..74ca59206 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.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 { formOptions } from '../../src' +import { sleep } from '../../src/pipeline/utils.lib' import { promiseWithResolvers } from '../test-utils' describe('InternalFormApi - fieldErrorTarget', () => { @@ -958,6 +959,45 @@ describe('InternalFormApi - setFieldValue', () => { }) describe('InternalFormApi - reset', () => { + it('cancels mounted field validation and its waiting validators when the form resets', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + let signal: AbortSignal | undefined + const waitingValidator = vi.fn(() => 'Late mount error') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['mount'], + run: (context) => { + signal = context.signal + return sleep(100).then(() => null) + }, + }, + { triggers: ['mount'], bailIfInvalid: true, run: waitingValidator }, + ], + }) + onTestFinished(field._register()) + expect(signal?.aborted).toBe(false) + expect(form.state.isValidating).toBe(true) + + form.reset() + + expect(signal?.aborted).toBe(true) + expect(form.state.isValidating).toBe(false) + expect(form.getFieldMeta('name')).toBeUndefined() + + await vi.advanceTimersByTimeAsync(100) + + expect(waitingValidator).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + expect(form.state.canSubmit).toBe(true) + }) + it('notifies parent and child reset listeners with their values before removing the fields', () => { const parentListener = vi.fn() const childListener = vi.fn() @@ -1115,7 +1155,7 @@ describe('InternalFormApi - reset', () => { expect(form.state.errors).toEqual([{ message: 'Current error' }]) }) - it('ignores the result of a submission that finishes after reset', async ({ + it('returns the result of a detached submission without changing reset state', async ({ onTestFinished, }) => { const { promise: request, resolve: finishRequest } = promiseWithResolvers() @@ -1141,7 +1181,9 @@ describe('InternalFormApi - reset', () => { finishRequest() - await expect(submission).resolves.toEqual([]) + await expect(submission).resolves.toEqual([ + { message: 'Late submit error' }, + ]) expect(form.state).toMatchObject({ values: { name: 'reset value' }, errors: [], @@ -1553,8 +1595,7 @@ describe('InternalFormApi - _clearEventErrors', () => { expect(form.state.isValid).toBe(false) }) - // TODO: Clearing onSubmit errors on blur is debated and may change. - it('clears onSubmit field errors only for the field that blurs', async () => { + it('preserves onSubmit field errors on blur', async () => { const form = new InternalFormApi({ defaultValues: { name: '', email: '' }, onSubmit: ({ createErrorMap }) => @@ -1577,7 +1618,7 @@ describe('InternalFormApi - _clearEventErrors', () => { nameField.handleBlur() await expect.poll(() => form.state.isValidating).toBe(false) - expect(nameField.errors).toEqual([]) + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) expect(emailField.errors).toEqual([{ message: 'Email is required' }]) expect(form.state.isValid).toBe(false) }) @@ -1600,8 +1641,7 @@ describe('InternalFormApi - _clearEventErrors', () => { expect(form.state.canSubmit).toBe(true) }) - // TODO clearing onSubmit errors on blur is debated and may change. - it('clears form-level onSubmit errors when a field blurs', async () => { + it('preserves form-level onSubmit errors when a field blurs', async () => { const form = new InternalFormApi({ defaultValues: { name: '', email: '' }, onSubmit: ({ createValidationError }) => @@ -1614,7 +1654,7 @@ describe('InternalFormApi - _clearEventErrors', () => { field.handleBlur() - expect(form.state.errors).toEqual([]) + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) }) it('clears submit-only validator errors when a field changes', async () => { diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index 009bf62e8..cfd9ee43c 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -1,17 +1,20 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../../src/FormApi/formOptions.public' -import { createValidator, createValidators } from '../../src/validation.public' +import { + createValidator, + createValidators, +} from '../../src/pipeline/validation/validation.public' import type { AnyFieldApi } from '../../src/FieldApi/FieldApi.public' import type { OnSubmitError } from '../../src/FormApi/formSubmit.public' import type { ServerFormState } from '../../src/FormApi/formServerState.public' -import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +import type { StandardSchemaV1Issue } from '../../src/validation/results/standardSchema.public' import type { CreateErrorMapFn, ParseIssuesFn, ParsedStandardSchemaIssues, ValidationIssue, -} from '../../src/validation/state.public' +} from '../../src/validation/results/results.public' describe('formOptions reuse', () => { it('keeps omitted validators empty when adding a submit handler', () => { @@ -919,7 +922,7 @@ describe('formOptions server state', () => { validationResults: [ { validatorIndex: 0, - result: { message: 'Invalid', code: 'server' as const }, + result: [{ message: 'Invalid', code: 'server' as const }], output: null, }, ], @@ -957,7 +960,7 @@ describe('formOptions server state', () => { validationResults: [ { validatorIndex: 2, - // @ts-expect-error Client-only issues cannot occur in server state. + // @ts-expect-error Server errors must be formatted as issue arrays or error maps. result: { message: 'Invalid', code: 'client' }, output: null, }, diff --git a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts index b9f376f1c..622d62b89 100644 --- a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts +++ b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { sleep } from '../../src/pipeline/utils.lib' describe('runSubmissionProcess', () => { it('applies form and field errors added to a submission error map', async () => { diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index c26a65b48..bb5e98197 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.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 { promiseWithResolvers } from '../test-utils' describe('form - submission handling', () => { describe('handleSubmit', () => { @@ -348,7 +349,7 @@ describe('form - submission handling', () => { expect(form.state.submissionAttempts).toBe(0) }) - it('skips onSubmit when a field validator throws during submit', async () => { + it('rejects a field exception and clears submission state', async () => { const error = new Error('Field validator failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onSubmit = vi.fn() @@ -371,26 +372,59 @@ describe('form - submission handling', () => { }) try { - const result = await form.handleSubmit() - - expect(result).toEqual([]) - expect(onSubmit).not.toHaveBeenCalled() - expect(onSubmitInvalid).toHaveBeenCalledWith({ - value: { name: '' }, - formApi: form, + await expect(form.handleSubmit()).rejects.toMatchObject({ + errors: [error], }) + expect(onSubmit).not.toHaveBeenCalled() + expect(onSubmitInvalid).not.toHaveBeenCalled() expect(form.state.isSubmitting).toBe(false) expect(form.state.isSubmitSuccessful).toBe(false) - expect(consoleSpy).toHaveBeenCalledWith( - 'Validator threw an error:', - error, - ) + expect(form.state.isValidating).toBe(false) + expect(consoleSpy).not.toHaveBeenCalled() } finally { consoleSpy.mockRestore() } }) - it('ignores rejected onSubmit errors after reset', async () => { + it('observes deferred field failures while sibling validation settles', async () => { + const error = new Error('Deferred field failure') + const failed = promiseWithResolvers() + const sibling = promiseWithResolvers() + const onSubmit = vi.fn() + const onSubmitInvalid = vi.fn() + const form = new InternalFormApi({ + defaultValues: { first: '', second: '' }, + onSubmit, + onSubmitInvalid, + }) + form._getOrCreateFieldApi({ + name: 'first', + validators: [{ triggers: [], run: () => failed.promise }], + }) + form._getOrCreateFieldApi({ + name: 'second', + validators: [{ triggers: [], run: () => sibling.promise }], + }) + const submit = form.handleSubmit() + const rejection = expect(submit).rejects.toMatchObject({ + errors: [error], + }) + failed.reject(error) + await expect + .poll(() => form.getFieldMeta('first')?.isValidating) + .toBe(false) + expect(form.state.isSubmitting).toBe(true) + expect(form.state.isValidating).toBe(true) + sibling.resolve(null) + await rejection + expect(form.state.isSubmitting).toBe(false) + expect(form.state.isSubmitSuccessful).toBe(false) + expect(form.state.isValidating).toBe(false) + expect(onSubmit).not.toHaveBeenCalled() + expect(onSubmitInvalid).not.toHaveBeenCalled() + }) + + it('observes rejected onSubmit errors after reset without changing reset state', async () => { let rejectSubmit!: (error: Error) => void const error = new Error('Late submit failure') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -413,7 +447,7 @@ describe('form - submission handling', () => { const result = await submitPromise expect(result).toEqual([]) - expect(consoleSpy).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(error) expect(form.state.values).toEqual({ name: 'reset value' }) expect(form.state.isSubmitting).toBe(false) expect(form.state.submissionAttempts).toBe(0) @@ -506,7 +540,6 @@ describe('form - submission handling', () => { expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: [undefined] }), ) - expect(form._validatorInstances?.[0]?.hasOutput).toBe(false) }) it('skips bailIfInvalid form validators when field validators fail first', async () => { @@ -562,13 +595,13 @@ describe('form - submission handling', () => { expect(thirdValidatorFn).not.toHaveBeenCalled() }) - it('skips all remaining validators after bailIfInvalid on submit when errors exist', async () => { + it('continues unflagged submit validators after an actual bail skip', async () => { const secondValidatorFn = vi .fn() .mockImplementation(() => ({ message: 'Should not run' })) const thirdValidatorFn = vi .fn() - .mockImplementation(() => ({ message: 'Should also not run' })) + .mockImplementation(() => ({ message: 'Unflagged error' })) const form = new InternalFormApi({ defaultValues: { name: '' }, validators: [ @@ -588,9 +621,12 @@ describe('form - submission handling', () => { ], }) await form.handleSubmit() - expect(form.state.errors).toEqual([{ message: 'First error' }]) + expect(form.state.errors).toEqual([ + { message: 'First error' }, + { message: 'Unflagged error' }, + ]) expect(secondValidatorFn).not.toHaveBeenCalled() - expect(thirdValidatorFn).not.toHaveBeenCalled() + expect(thirdValidatorFn).toHaveBeenCalledOnce() }) it('skips validators with skipOnSubmit: true', async () => { @@ -697,7 +733,7 @@ describe('form - submission handling', () => { expect(validatorFn).toHaveBeenCalledOnce() }) - it('returns the same promise when handleSubmit is called multiple times during submission', async () => { + it('starts a distinct attempt for every handleSubmit call', async () => { let resolveValidator!: () => void const validatorPromise = new Promise((resolve) => { resolveValidator = resolve @@ -725,7 +761,7 @@ describe('form - submission handling', () => { // Second handleSubmit call while first is still pending const secondSubmitPromise = form.handleSubmit() - expect(firstSubmitPromise).toBe(secondSubmitPromise) + expect(firstSubmitPromise).not.toBe(secondSubmitPromise) resolveValidator() diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 5a2850d20..d3b413f11 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' +import { promiseWithResolvers } from '../test-utils' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import type { StandardSchemaV1 } from '../../src/standardSchema.public' +import type { StandardSchemaV1 } from '../../src/validation/results/standardSchema.public' describe('form - validation', () => { describe('validate', () => { @@ -82,36 +83,6 @@ describe('form - validation', () => { expect(form.state.errors).toEqual([{ message: 'Name is required' }]) }) - it('parses Standard Schema issues from field validators', async () => { - const schema = z.string().min(1, 'Name is required') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: (context: any) => { - const result = schema.safeParse(context.value) - - if (!result.success) { - return result.error.issues - } - - return null - }, - triggers: [], - }, - ], - }) - - await field._runFieldValidation('submit') - - expect(field.errors).toEqual([ - expect.objectContaining({ message: 'Name is required' }), - ]) - }) - it('parses Standard Schema issues from form validators', async () => { const schema = z.object({ user: z.object({ @@ -156,7 +127,7 @@ describe('form - validation', () => { ], }) const result = await form.validate('submit') - expect(result).toEqual([[{ message: 'Error 1' }, { message: 'Error 2' }]]) + expect(result).toEqual([{ message: 'Error 1' }, { message: 'Error 2' }]) expect(form.state.errors).toEqual([ { message: 'Error 1' }, { message: 'Error 2' }, @@ -214,11 +185,12 @@ describe('form - validation', () => { ], }) - const result = await form._runFormValidation('submit', { + const result = form._runFormValidation('submit', { + catchErrors: false, onResult: false, }) - expect(result.results).toHaveLength(1) + expect(result?.collector.getResults()).toHaveLength(1) expect(form.state.errors).toEqual([]) }) @@ -264,33 +236,6 @@ describe('form - validation', () => { vi.useRealTimers() }) - it('tracks isValidating while a field validator is pending', async () => { - vi.useFakeTimers() - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - triggers: ['change'], - run: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)) - return null - }, - }, - ], - }) - - const validatePromise = field._runFieldValidation('change') - - expect(form.state.isValidating).toBe(true) - - await vi.runAllTimersAsync() - await validatePromise - - expect(form.state.isValidating).toBe(false) - vi.useRealTimers() - }) - it('maintains validator order with async debounced and sync validators', async () => { const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -486,13 +431,13 @@ describe('form - validation', () => { expect(form.state.errors).toEqual([{ message: 'Second error' }]) }) - it('skips all remaining validators after bailIfInvalid when errors exist', async () => { + it('skips only flagged validators after an earlier validation error', async () => { const thirdValidatorFn = vi .fn() .mockImplementation(() => ({ message: 'Should not run' })) const fourthValidatorFn = vi .fn() - .mockImplementation(() => ({ message: 'Should also not run' })) + .mockImplementation(() => ({ message: 'Unflagged error' })) const form = new InternalFormApi({ defaultValues: { name: '' }, validators: [ @@ -512,21 +457,21 @@ describe('form - validation', () => { ], }) - // Start validation await form.validate('blur') - // First validator should have populated errors - expect(form.state.errors).toEqual([{ message: 'Sync error' }]) + expect(form.state.errors).toEqual([ + { message: 'Sync error' }, + { message: 'Unflagged error' }, + ]) - // Third validator (with bailIfInvalid) should NOT have been called expect(thirdValidatorFn).not.toHaveBeenCalled() - // Fourth validator (after bailIfInvalid) should also NOT have been called - expect(fourthValidatorFn).not.toHaveBeenCalled() + expect(fourthValidatorFn).toHaveBeenCalledOnce() }) - it('skips bailIfInvalid validators after an earlier validator throws', async () => { + it('aggregates execution errors while continuing unflagged validators', async () => { const error = new Error('validator failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const laterValidator = vi.fn(() => null) const bailedValidator = vi.fn(() => ({ message: 'Should not run' })) const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -542,17 +487,18 @@ describe('form - validation', () => { run: bailedValidator, triggers: ['change'], }, + { run: laterValidator, triggers: ['change'] }, ], }) try { - await form.validate('change') + await expect(form.validate('change')).rejects.toMatchObject({ + errors: [error], + }) expect(bailedValidator).not.toHaveBeenCalled() - expect(consoleSpy).toHaveBeenCalledWith( - 'Validator threw an error:', - error, - ) + expect(laterValidator).toHaveBeenCalledOnce() + expect(consoleSpy).not.toHaveBeenCalled() } finally { consoleSpy.mockRestore() } @@ -721,6 +667,7 @@ describe('form - validation', () => { }, { triggers: ['mount'], + bailIfInvalid: true, run: secondValidator, }, ], @@ -786,7 +733,7 @@ describe('form - validation', () => { expect(form.state.isValidating).toBe(false) }) - it('logs thrown mount validator errors without storing them', () => { + it('logs thrown mount validator errors without storing them', async () => { const error = new Error('Mount validator failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -803,7 +750,9 @@ describe('form - validation', () => { ], }) - expect(consoleSpy).toHaveBeenCalledWith(error) + await expect + .poll(() => consoleSpy) + .toHaveBeenCalledWith(expect.objectContaining({ errors: [error] })) expect(form.state.errors).toEqual([]) } finally { consoleSpy.mockRestore() @@ -827,9 +776,10 @@ describe('form - validation', () => { await vi.waitFor(() => expect(form.state.isValidating).toBe(false)) - expect(consoleSpy).toHaveBeenCalledWith(error) + await expect + .poll(() => consoleSpy) + .toHaveBeenCalledWith(expect.objectContaining({ errors: [error] })) expect(form.state.errors).toEqual([]) - expect(form._validatorInstances![0]!.abortController).toBeNull() } finally { consoleSpy.mockRestore() } @@ -859,9 +809,10 @@ describe('form - validation', () => { await vi.waitFor(() => expect(form.state.isValidating).toBe(false)) - expect(consoleSpy).toHaveBeenCalledWith(error) + await expect + .poll(() => consoleSpy) + .toHaveBeenCalledWith(expect.objectContaining({ errors: [error] })) expect(form.state.errors).toEqual([]) - expect(form._validatorInstances![0]!.abortController).toBeNull() } finally { consoleSpy.mockRestore() } @@ -1042,27 +993,6 @@ describe('form - validation', () => { expect(validatorFn).not.toHaveBeenCalled() }) - it('skips validation when explicitly disabled per update', async () => { - vi.useFakeTimers() - const validatorFn = vi - .fn() - .mockImplementation(() => ({ message: 'Change error' })) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: validatorFn, - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice', { causeValidation: false }) - await vi.runAllTimersAsync() - - expect(validatorFn).not.toHaveBeenCalled() - }) - it('debounces change validators across rapid updates', async () => { vi.useFakeTimers() const validatorFn = vi @@ -1402,25 +1332,6 @@ describe('form - validation', () => { expect(form.state.canSubmit).toBe(false) }) - it('sets canSubmit to false when field validator errors are present', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => ({ message: 'Name is required' }), - triggers: ['change'], - }, - ], - }) - - await field._runFieldValidation('change') - - expect(form.state.canSubmit).toBe(false) - }) - it('stores string field errors from form validators as message objects', async () => { const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -1465,203 +1376,6 @@ describe('form - validation', () => { expect(ageField.errors).toEqual([{ message: 'Age is required' }]) }) - it('filters public field errors with form-level errorVisibility', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', age: 0 }, - errorVisibility: ({ fieldState }) => fieldState.meta.isTouched, - validators: [ - { - run: () => ({ - fields: { - name: { message: 'Name is required' }, - age: { message: 'Age is required' }, - }, - }), - triggers: ['change'], - }, - ], - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const ageField = form._getOrCreateFieldApi({ name: 'age' }) - - nameField.handleChange('Alice', { causeValidation: false }) - await form.validate('change') - - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(nameField.meta.isValid).toBe(false) - expect(nameField.meta.original.errors).toEqual([ - { message: 'Name is required' }, - ]) - expect(nameField.meta.original.isValid).toBe(false) - - expect(ageField.errors).toEqual([]) - expect(ageField.meta.isValid).toBe(true) - expect(ageField.meta.original.errors).toEqual([ - { message: 'Age is required' }, - ]) - expect(ageField.meta.original.isValid).toBe(false) - }) - - it('lets fields override the form-level errorVisibility', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', age: 0 }, - errorVisibility: ({ fieldState }) => fieldState.meta.isTouched, - validators: [ - { - run: () => ({ - fields: { - age: { message: 'Age is required' }, - }, - }), - triggers: ['change'], - }, - ], - }) - const ageField = form._getOrCreateFieldApi({ - name: 'age', - errorVisibility: () => true, - }) - - await form.validate('change') - - expect(ageField.errors).toEqual([{ message: 'Age is required' }]) - expect(ageField.meta.isValid).toBe(false) - expect(ageField.meta.original.errors).toEqual([ - { message: 'Age is required' }, - ]) - }) - - it('reveals submit-attempted errors only after a submit attempt', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - errorVisibility: ({ state }) => state.submissionAttempts > 0, - validators: [ - { - run: () => ({ - fields: { - name: { message: 'Name is required' }, - }, - }), - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleBlur() - await form.validate('change') - expect(field.errors).toEqual([]) - - await form.handleSubmit() - expect(field.errors).toEqual([{ message: 'Name is required' }]) - }) - - it('reveals blurred-or-submit-attempted errors after blur or submit', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - errorVisibility: ({ state, fieldState }) => - fieldState.meta.isBlurred || state.submissionAttempts > 0, - validators: [ - { - run: () => ({ - fields: { - name: { message: 'Name is required' }, - email: { message: 'Email is required' }, - }, - }), - triggers: ['change'], - }, - ], - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - - nameField.handleBlur() - await form.validate('change') - - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([]) - - await form.handleSubmit() - - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('passes current scoped state and pre-visibility field state to errorVisibility', async () => { - const errorVisibility = vi.fn( - ({ state, fieldState }) => - state.values.name === 'Alice' && - fieldState.value === 'Alice' && - fieldState.meta.isTouched && - fieldState.meta.isDirty, - ) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - errorVisibility, - validators: [ - { - run: () => ({ fields: { name: 'Required' } }), - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice', { causeValidation: false }) - await form.validate('change') - - expect(field.errors).toEqual([{ message: 'Required' }]) - expect(errorVisibility).toHaveBeenCalled() - const [{ fieldState }] = errorVisibility.mock.lastCall! - expect(fieldState).toMatchObject({ - value: 'Alice', - meta: { - isTouched: true, - isSelfTouched: true, - isDirty: true, - isSelfDirty: true, - isPristine: false, - }, - }) - expect('errors' in fieldState.meta).toBe(false) - expect('isValid' in fieldState.meta).toBe(false) - }) - - it('passes derived form error and validity state to errorVisibility', async () => { - const errorVisibility = vi.fn( - ({ state }) => - state.errors[0]?.message === 'Form error' && - !state.isValid && - state.isInvalid && - !state.canSubmit, - ) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - errorVisibility, - validators: [ - { - run: () => ({ - form: 'Form error', - fields: { name: 'Name error' }, - }), - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('change') - - expect(field.errors).toEqual([{ message: 'Name error' }]) - expect(errorVisibility).toHaveBeenCalled() - expect(errorVisibility.mock.lastCall?.[0].state).toMatchObject({ - errors: [{ message: 'Form error' }], - isValid: false, - isInvalid: true, - canSubmit: false, - }) - }) - it('handles array error format in field-level errors', async () => { const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -1690,59 +1404,6 @@ describe('form - validation', () => { expect(lastNameField.errors).toEqual([{ message: 'Error 3' }]) }) - it('combines field-level and form-level errors in field.errors', async () => { - vi.useFakeTimers() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: () => ({ - fields: { - name: { message: 'Form-level error' }, - }, - }), - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { run: () => ({ message: 'Field-level error' }), triggers: [] }, - ], - }) - 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' }]) - }) - - it('stores string field validator results as message objects', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [{ run: () => 'Field-level error', triggers: [] }], - }) - void field.atom - await field._runFieldValidation('submit') - expect(field.errors).toEqual([{ message: 'Field-level error' }]) - }) - it('clears field errors when validator no longer reports them', async () => { let shouldError = true const form = new InternalFormApi({ @@ -1929,3 +1590,246 @@ describe('form - validation', () => { }) }) }) + +describe('source-owned error storage', () => { + it('replaces a complete form result while preserving other sources', async () => { + const validator = vi + .fn() + .mockReturnValueOnce({ + form: 'Root', + fields: { name: 'Name', email: 'Email' }, + }) + .mockReturnValueOnce({ fields: { email: 'Updated email' } }) + .mockReturnValue(null) + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ triggers: [], run: validator }], + }) + const name = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: [], run: () => 'Field source' }], + }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.handleSubmit() + expect(name.errors).toEqual([ + { message: 'Field source' }, + { message: 'Name' }, + ]) + expect(form.state.errors).toEqual([{ message: 'Root' }]) + await form.validate('submit') + expect(name.errors).toEqual([{ message: 'Field source' }]) + expect(email.errors).toEqual([{ message: 'Updated email' }]) + expect(form.state.errors).toEqual([]) + await form.validate('submit') + expect(email.errors).toEqual([]) + expect(name.errors).toEqual([{ message: 'Field source' }]) + expect(form.state.isValid).toBe(false) + }) + + it('invalidates form errors at the changed field and all ancestor targets', async () => { + const form = new InternalFormApi({ + defaultValues: { address: { city: '' }, email: '' }, + validators: [ + { + triggers: [], + run: () => ({ + form: 'Root', + fields: { + address: 'Address', + 'address.city': 'City', + email: 'Email', + }, + }), + }, + ], + }) + const address = form._getOrCreateFieldApi({ name: 'address' }) + const city = form._getOrCreateFieldApi({ name: 'address.city' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.handleSubmit() + city.handleChange('London', { causeValidation: false }) + expect(form.state.errors).toEqual([]) + expect(address.errors).toEqual([]) + expect(city.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Email' }]) + expect(form.state.isValid).toBe(false) + email.handleChange('name@example.com', { causeValidation: false }) + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('clears an entire boundary contribution while retaining unrelated targets', async () => { + const form = new InternalFormApi({ + defaultValues: { address: { city: '', postcode: '' }, email: '' }, + validators: [ + { + triggers: [], + run: () => ({ + fields: { + 'address.city': 'City', + 'address.postcode': 'Postcode', + email: 'Email', + }, + }), + }, + ], + }) + const address = form._getOrCreateFieldApi({ + name: 'address', + errorBoundary: true, + }) + const city = form._getOrCreateFieldApi({ name: 'address.city' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.handleSubmit() + expect(address.errors).toEqual([ + { message: 'City' }, + { message: 'Postcode' }, + ]) + city.handleChange('London', { causeValidation: false }) + expect(address.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Email' }]) + expect(form.state.isValid).toBe(false) + }) + + it('resetting one target leaves the source contribution at other targets intact', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [ + { + triggers: [], + run: () => ({ + form: 'Root', + fields: { name: 'Name', email: 'Email' }, + }), + }, + ], + }) + const name = form._getOrCreateFieldApi({ name: 'name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.handleSubmit() + name.reset() + expect(name.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Email' }]) + expect(form.state.errors).toEqual([{ message: 'Root' }]) + expect(form.state.isValid).toBe(false) + email.handleChange('valid', { causeValidation: false }) + expect(email.errors).toEqual([]) + expect(form.state.errors).toEqual([]) + expect(form.state.isValid).toBe(true) + }) + + it('retains stale errors when change predicates enable validation after listeners run', async () => { + const pending = promiseWithResolvers() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: [ + { trigger: 'change', when: ({ formApi }) => formApi.state.isDirty }, + ], + run: ({ event }) => + event === 'submit' + ? { form: 'Root', fields: { name: 'Name' } } + : pending.promise, + }, + ], + }) + const name = form._getOrCreateFieldApi({ name: 'name' }) + await form.handleSubmit() + name.handleChange('new') + expect(form.state.errors).toEqual([{ message: 'Root' }]) + expect(name.errors).toEqual([{ message: 'Name' }]) + pending.resolve(null) + await expect.poll(() => form.state.isValidating).toBe(false) + expect(form.state.isValid).toBe(true) + }) + + it('uses one change selection after field listeners change validator eligibility', async () => { + const pending = promiseWithResolvers() + const when = vi.fn(({ formApi }) => formApi.state.values.enabled) + const form = new InternalFormApi({ + defaultValues: { name: '', enabled: false }, + validators: [ + { + triggers: [{ trigger: 'change', when }], + run: ({ event }) => (event === 'submit' ? 'Root' : pending.promise), + }, + ], + }) + const name = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + run: () => + form.setFieldValue('enabled', true, { causeValidation: false }), + }, + ], + }) + await form.handleSubmit() + name.handleChange('new') + expect(when).toHaveBeenCalledOnce() + // The listener's separate write invalidates the old result; the replacement still runs. + expect(form.state.isValidating).toBe(true) + pending.resolve(null) + await expect.poll(() => form.state.isValidating).toBe(false) + expect(form.state.errors).toEqual([]) + }) + + it('clears stale contributions at actual form bail skips without clearing siblings', async () => { + const pending = promiseWithResolvers() + const skipped = vi.fn(() => ({ + form: 'Stored root', + fields: { name: 'Stored name', email: 'Stored email' }, + })) + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [ + { + triggers: ['change'], + run: ({ event }) => (event === 'submit' ? null : pending.promise), + }, + { triggers: ['change'], bailIfInvalid: true, run: skipped }, + ], + }) + const name = form._getOrCreateFieldApi({ name: 'name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + await form.handleSubmit() + name.handleChange('new') + expect(name.errors).toEqual([{ message: 'Stored name' }]) + pending.resolve('Current root') + await expect.poll(() => form.state.isValidating).toBe(false) + expect(skipped).toHaveBeenCalledOnce() + expect(form.state.errors).toEqual([{ message: 'Current root' }]) + expect(name.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Stored email' }]) + }) +}) + +it('publishes reset values and cleared source errors in one reactive update', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'old' }, + validators: [ + { + triggers: [], + run: () => ({ form: 'Root', fields: { name: 'Name' } }), + }, + ], + }) + await form.handleSubmit() + const snapshots: Array<{ value: string; errors: unknown; isValid: boolean }> = + [] + const subscription = form.atom.subscribe((state) => + snapshots.push({ + value: state.values.name, + errors: state.errors, + isValid: state.isValid, + }), + ) + try { + form.reset({ name: 'fresh' }) + expect(snapshots).toEqual([{ value: 'fresh', errors: [], isValid: true }]) + } finally { + subscription.unsubscribe() + } +}) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 68cf3823c..4f2f13409 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -2,7 +2,9 @@ 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' +import { clearArray } from '../../src/utils.lib' +import { validationSourceScopes } from '../../src/validation/errorStorage.lib' +import { promiseWithResolvers } from '../test-utils' describe('FormGroupApi', () => { it('runs synchronous mount validators and stores group errors', () => { @@ -179,7 +181,7 @@ describe('FormGroupApi', () => { expect(form.state.isValidating).toBe(false) }) - it('only removes group-owned validation counts when canceled', async () => { + it('preserves a newer independent descendant field run when the group cancels', async () => { const resolvers: Array<(value: null) => void> = [] const fieldValidator = vi.fn( () => @@ -190,8 +192,8 @@ describe('FormGroupApi', () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: '' } }, }) - const groupField = form._getOrCreateFieldApi({ - name: 'guestDetails', + const nameField = form._getOrCreateFieldApi({ + name: 'guestDetails.name', validators: [{ triggers: [], run: fieldValidator }], }) const group = new InternalFormGroupApi({ @@ -202,30 +204,27 @@ describe('FormGroupApi', () => { const groupValidation = group.validate('submit') await vi.waitFor(() => expect(fieldValidator).toHaveBeenCalledOnce()) - const independentValidation = groupField._runFieldValidation('submit') + const independentValidation = nameField._runFieldValidation('submit', { + catchErrors: false, + })?.pending await vi.waitFor(() => expect(fieldValidator).toHaveBeenCalledTimes(2)) - expect(groupField._getBaseMeta()._validationCount).toBe(3) - group.reset() - expect(groupField._getBaseMeta()._validationCount).toBe(1) - expect(groupField.meta.isSelfValidating).toBe(true) - expect(groupField.meta.isValidating).toBe(true) + expect(nameField.meta.isSelfValidating).toBe(true) + expect(nameField.meta.isValidating).toBe(true) expect(form.state.isValidating).toBe(true) resolvers[0]!(null) await groupValidation - expect(groupField._getBaseMeta()._validationCount).toBe(1) - expect(groupField.meta.isSelfValidating).toBe(true) - expect(groupField.meta.isValidating).toBe(true) + expect(nameField.meta.isSelfValidating).toBe(true) + expect(nameField.meta.isValidating).toBe(true) resolvers[1]!(null) await independentValidation - expect(groupField._getBaseMeta()._validationCount).toBe(0) - expect(groupField.meta.isValidating).toBe(false) + expect(nameField.meta.isValidating).toBe(false) expect(form.state.isValidating).toBe(false) }) @@ -365,7 +364,6 @@ describe('FormGroupApi', () => { expect(instance?.definition).toBe(nextDefinition) expect(instance?.owner).toBe(group) expect(instance?.scope).toBe(validationSourceScopes.group) - expect(instance?.revision).toBe(1) }) it('warns when the group validator array length changes after initialization', () => { @@ -392,30 +390,35 @@ describe('FormGroupApi', () => { warn.mockRestore() }) - it('resets validator runtime during cleanup and preserves remount identity', () => { + it('cancels validation during cleanup and preserves remount identity', async () => { + const pending = promiseWithResolvers() + let signal: AbortSignal | undefined const form = new InternalFormApi({ defaultValues: { guestDetails: { name: 'Tony' } }, }) const group = new InternalFormGroupApi({ form, name: 'guestDetails', - validators: [{ run: () => null, triggers: [] }], + validators: [ + { + triggers: [], + run: (context) => { + signal = context.signal + return pending.promise + }, + }, + ], }) const instance = group._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - + const validation = group.validate('submit') group._cleanup() group.mount() - + await validation + expect(signal?.aborted).toBe(true) expect(group._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) + expect(group.state.isValidating).toBe(false) expect(instance?.disposed).toBe(false) + pending.resolve(null) }) it('stores the group on its trie node and follows that node when it moves', () => { @@ -744,11 +747,13 @@ describe('FormGroupApi', () => { expect(group.state.isSubmitSuccessful).toBe(false) finishInvalidSubmit() - await expect(submitPromise).resolves.toEqual(['Group is invalid']) + await expect(submitPromise).resolves.toEqual([ + { message: 'Group is invalid' }, + ]) expect(group.state.isSubmitting).toBe(false) }) - it('calls onSubmitInvalid when a group validator throws', async () => { + it('rejects a group validator exception without treating it as invalid', async () => { const error = new Error('Group validator failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onSubmit = vi.fn() @@ -772,25 +777,21 @@ describe('FormGroupApi', () => { }) try { - await expect(group.handleSubmit()).resolves.toEqual([]) + await expect(group.handleSubmit()).rejects.toMatchObject({ + errors: [error], + }) expect(onSubmit).not.toHaveBeenCalled() - expect(onSubmitInvalid).toHaveBeenCalledWith({ - value: { name: '' }, - formApi: form, - groupApi: group, - }) + expect(onSubmitInvalid).not.toHaveBeenCalled() expect(group.state.isSubmitSuccessful).toBe(false) - expect(consoleSpy).toHaveBeenCalledWith( - 'Validator threw an error:', - error, - ) + expect(consoleSpy).not.toHaveBeenCalled() + expect(group.state.isSubmitting).toBe(false) } finally { consoleSpy.mockRestore() } }) - it('calls onSubmitInvalid when a grouped field validator throws', async () => { + it('rejects a grouped field exception and clears submission state', async () => { const error = new Error('Field validator failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onSubmit = vi.fn() @@ -817,20 +818,113 @@ describe('FormGroupApi', () => { }) try { - await expect(group.handleSubmit()).resolves.toEqual([]) + await expect(group.handleSubmit()).rejects.toMatchObject({ + errors: [error], + }) expect(onSubmit).not.toHaveBeenCalled() - expect(onSubmitInvalid).toHaveBeenCalledOnce() + expect(onSubmitInvalid).not.toHaveBeenCalled() expect(group.state.isSubmitSuccessful).toBe(false) - expect(consoleSpy).toHaveBeenCalledWith( - 'Validator threw an error:', - error, - ) + expect(group.state.isSubmitting).toBe(false) + expect(form.state.isValidating).toBe(false) + expect(consoleSpy).not.toHaveBeenCalled() } finally { consoleSpy.mockRestore() } }) + it('observes a deferred field exception while legacy group validation settles', async () => { + const error = new Error('Deferred field failure') + const failed = promiseWithResolvers() + const groupWork = promiseWithResolvers() + const onSubmit = vi.fn() + const onSubmitInvalid = vi.fn() + const form = new InternalFormApi({ + defaultValues: { details: { name: '' } }, + }) + form._getOrCreateFieldApi({ + name: 'details.name', + validators: [{ triggers: [], run: () => failed.promise }], + }) + const group = new InternalFormGroupApi({ + form, + name: 'details', + validators: [{ triggers: [], run: () => groupWork.promise }], + onSubmit, + onSubmitInvalid, + }) + const submit = group.handleSubmit() + const rejection = expect(submit).rejects.toMatchObject({ errors: [error] }) + failed.reject(error) + await expect + .poll(() => form.getFieldMeta('details.name')?.isValidating) + .toBe(false) + expect(group.state.isSubmitting).toBe(true) + expect(group.state.isValidating).toBe(true) + groupWork.resolve(null) + await rejection + expect(group.state.isSubmitting).toBe(false) + expect(group.state.isSubmitSuccessful).toBe(false) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + expect(onSubmit).not.toHaveBeenCalled() + expect(onSubmitInvalid).not.toHaveBeenCalled() + }) + + it('cancels a registered field run before its waiting validator starts', async () => { + const pending = promiseWithResolvers() + const queued = vi.fn() + const form = new InternalFormApi({ + defaultValues: { details: { name: '' } }, + }) + form._getOrCreateFieldApi({ + name: 'details.name', + validators: [ + { triggers: [], run: () => pending.promise }, + { triggers: [], bailIfInvalid: true, run: queued }, + ], + }) + const group = new InternalFormGroupApi({ form, name: 'details' }) + const validation = group.validate('submit') + expect(group.state.isValidating).toBe(true) + group.reset() + await validation + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + pending.resolve(null) + await pending.promise + expect(queued).not.toHaveBeenCalled() + }) + + it('propagates field selection failures through explicit group validation', async () => { + const error = new Error('Selection failure') + const form = new InternalFormApi({ + defaultValues: { details: { name: '' } }, + }) + form._getOrCreateFieldApi({ + name: 'details.name', + validators: [ + { + triggers: [ + { + trigger: 'change', + when: () => { + throw error + }, + }, + ], + run: () => null, + }, + ], + }) + const group = new InternalFormGroupApi({ form, name: 'details' }) + await expect(group.validate('change')).rejects.toMatchObject({ + errors: [error], + }) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + it('calls onSubmitInvalid and preserves a rejected group onSubmit', async () => { const error = new Error('Group submit failed') const onSubmitInvalid = vi.fn() @@ -916,7 +1010,6 @@ describe('FormGroupApi', () => { expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: [undefined] }), ) - expect(group._validatorInstances?.[0]?.hasOutput).toBe(false) }) it('keeps submission lifecycle independent between sibling groups', async () => { @@ -1101,8 +1194,9 @@ describe('FormGroupApi', () => { new InternalFormGroupApi({ form, name: 'billingDetails' }) guestField.handleChange('Tony') - await billingField._runFieldValidation('submit') - states.length = 0 + await billingField._runFieldValidation('submit', { catchErrors: false }) + ?.pending + clearArray(states) void guestField.meta @@ -1168,14 +1262,6 @@ describe('FormGroupApi', () => { await group.validate('submit') expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - 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) }) @@ -1231,22 +1317,6 @@ describe('FormGroupApi', () => { { message: 'Group name error' }, { message: 'Root name error' }, ]) - 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', - }) }) it('keeps sibling group validator errors independently owned', async () => { @@ -1574,7 +1644,9 @@ describe('FormGroupApi', () => { name: 'guestDetails', }) - const validating = nameField._runFieldValidation('submit') + const validating = nameField._runFieldValidation('submit', { + catchErrors: false, + })?.pending await vi.waitFor(() => expect(group.state.isValidating).toBe(true)) expect(group._groupField.meta.isSelfValidating).toBe(false) @@ -1860,7 +1932,7 @@ describe('FormGroupApi', () => { expect(group.state.errors).toEqual([]) }) - it('only clears the blurred field when a group validator skips blur', async () => { + it('preserves group errors when a validator does not run on blur', async () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: '', email: '' } }, }) @@ -1888,7 +1960,7 @@ describe('FormGroupApi', () => { emailField.handleBlur() expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) }) it('replaces submit errors when the group validator runs on change', async () => { @@ -2092,3 +2164,39 @@ describe('FormGroupApi', () => { expect(form.state.isDirty).toBe(true) }) }) + +describe('group error storage', () => { + it('clears an actual skipped group contribution only along the change path', async () => { + const pending = promiseWithResolvers() + const skipped = vi.fn(() => ({ + form: 'Stored group', + fields: { name: 'Stored name', email: 'Stored email' }, + })) + const form = new InternalFormApi({ + defaultValues: { details: { name: '', email: '' } }, + }) + const name = form._getOrCreateFieldApi({ name: 'details.name' }) + const email = form._getOrCreateFieldApi({ name: 'details.email' }) + const group = new InternalFormGroupApi({ + form, + name: 'details', + validators: [ + { + triggers: ['change'], + run: ({ event }) => (event === 'submit' ? null : pending.promise), + }, + { triggers: ['change'], bailIfInvalid: true, run: skipped }, + ], + }) + await group.handleSubmit() + name.handleChange('new') + expect(group.state.errors).toEqual([{ message: 'Stored group' }]) + expect(name.errors).toEqual([{ message: 'Stored name' }]) + pending.resolve('Current group') + await expect.poll(() => group.state.isValidating).toBe(false) + expect(skipped).toHaveBeenCalledOnce() + expect(group.state.errors).toEqual([{ message: 'Current group' }]) + expect(name.errors).toEqual([]) + expect(email.errors).toEqual([{ message: 'Stored email' }]) + }) +}) diff --git a/packages/form-core/tests/ListenerInstance.spec.ts b/packages/form-core/tests/ListenerInstance.spec.ts index ee0a0b979..dbe034ce8 100644 --- a/packages/form-core/tests/ListenerInstance.spec.ts +++ b/packages/form-core/tests/ListenerInstance.spec.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { - InternalListenerInstance, - reconcileListenerInstances, -} from '../src/ListenerInstance.lib' -import type { AnyFieldListener } from '../src/listeners.public' +import { ListenerInstance } from '../src/pipeline/listeners/ListenerInstance.lib' +import type { AnyFieldListener } from '../src/pipeline/listeners/listeners.public' function createDefinition(label: string): AnyFieldListener { return { @@ -12,74 +9,36 @@ function createDefinition(label: string): AnyFieldListener { } } -describe('InternalListenerInstance', () => { +describe('ListenerInstance', () => { it('updates its definition without replacing runtime identity', () => { - const owner = { name: 'field' } const initialDefinition = createDefinition('initial') - const instance = new InternalListenerInstance({ + const instance = new ListenerInstance({ definition: initialDefinition, - owner, - index: 2, + scope: 'field', }) - const source = { name: 'source' } const nextDefinition = createDefinition('next') - instance.setResolvedWatchField('source', source) instance.updateDefinition(nextDefinition) expect(instance.definition).toBe(nextDefinition) - expect(instance.owner).toBe(owner) - expect(instance.index).toBe(2) - expect(instance.revision).toBe(1) - expect(instance.resolvedWatchFields?.get('source')).toBe(source) - }) - - it('resets and disposes pending execution safely', async () => { - vi.useFakeTimers() - const instance = new InternalListenerInstance({ - definition: createDefinition('listener'), - owner: {}, - }) - const run = vi.fn((_value: string) => {}) - const debouncer = instance.getOrCreateDebouncer(run, 100) - debouncer?.maybeExecute('cancelled') - - instance.resetRuntime() - await vi.advanceTimersByTimeAsync(100) - - expect(run).not.toHaveBeenCalled() - expect(instance.debouncer).toBeNull() - expect(instance.disposed).toBe(false) - - instance.setResolvedWatchField('source', {}) - const onBeforeDispose = vi.fn() - instance.dispose(onBeforeDispose) - instance.dispose(onBeforeDispose) - - expect(onBeforeDispose).toHaveBeenCalledOnce() - expect(instance.resolvedWatchFields).toBeNull() - expect(instance.disposed).toBe(true) - expect(instance.getOrCreateDebouncer(run, 100)).toBeNull() - vi.useRealTimers() }) }) -describe('reconcileListenerInstances', () => { +describe('ListenerInstance.update', () => { it('normalizes empty definitions and runs owner cleanup', () => { - const owner = { name: 'form' } - const instances = reconcileListenerInstances({ + const instances = ListenerInstance.update({ definitions: [createDefinition('listener')], instances: null, - owner, + scope: 'field', }) const instance = instances![0]! const onBeforeDispose = vi.fn() expect( - reconcileListenerInstances({ + ListenerInstance.update({ definitions: [], instances, - owner, + scope: 'field', onBeforeDispose, }), ).toBeNull() diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts index b74b0e796..93b12dbc6 100644 --- a/packages/form-core/tests/ValidatorInstance.spec.ts +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -1,429 +1,232 @@ -import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { LiteDebouncer } from '@tanstack/pacer-lite' -import { - InternalValidatorInstance, - reconcileValidatorInstances, -} from '../src/ValidatorInstance.lib' -import { validationSourceScopes } from '../src/ValidationSourceInstance.lib' -import type { FieldValidator } from '../src' - -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< - 'field', - typeof owner, - string, - { name: string }, - string | undefined, - TestDebouncedFn - >({ - definition, - owner, - scope: 'field', - }) -} - -describe('InternalValidatorInstance', () => { - afterEach(() => { - vi.useRealTimers() +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' +import { InternalFormApi, InternalFormGroupApi } from '../src/internals' +import { promiseWithResolvers } from './test-utils' + +describe('ValidatorInstance', () => { + it('replaces mount errors when the same validator runs after a change', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Ada' }, + validators: [ + { + triggers: ['mount', 'change'], + run: ({ value }) => `Invalid ${value.name}`, + }, + ], + }) + + expect(form.state.errors).toEqual([{ message: 'Invalid Ada' }]) + expect(form.state.isValidating).toBe(false) + + form.setFieldValue('name', 'Grace', { causeValidation: false }) + const validating = form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Invalid Grace' }]) + expect(form.state.isValidating).toBe(false) + await expect(validating).resolves.toEqual([{ message: 'Invalid Grace' }]) }) - it('uses the shared scope priorities for validation source ordering', () => { - expect(validationSourceScopes).toEqual({ - field: 0, - group: 1, - form: 2, - onSubmit: 3, + it('orders errors by validator position when selected validators finish out of order', async () => { + const pending = promiseWithResolvers() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { triggers: ['blur'], run: () => 'Blur error' }, + { triggers: ['change'], run: () => pending.promise }, + { triggers: ['change'], run: () => 'Last error' }, + ], }) - }) + onTestFinished(() => form.reset()) - it('stores its installation and starts with empty runtime state', () => { - const first = createInstance() - const second = createInstance() + const validating = form.validate('change') - expect(first).not.toBe(second) - expect(first.definition).toMatchObject({ - run: expect.any(Function), - triggers: ['change'], - }) - expect(first.owner).toEqual({ name: 'name' }) - expect(first.scope).toBe(validationSourceScopes.field) - expect(first.index).toBe(0) - expect(first.abortController).toBeNull() - expect(first.debouncer).toBeNull() - expect(first.output).toBeUndefined() - expect(first.hasOutput).toBe(false) - expect(first.errorTargets).toBeNull() - expect(first.resolvedWatchFields).toBeNull() - expect(first.didRunOnMount).toBe(false) - expect(first.revision).toBe(0) - expect(first.disposed).toBe(false) - - first.addErrorTarget('temporary') - first.deleteErrorTarget('temporary') - first.setResolvedWatchField('temporary', { name: 'temporary' }) - first.deleteResolvedWatchField('temporary') - - expect(first.errorTargets).toBeNull() - expect(first.resolvedWatchFields).toBeNull() - - expectTypeOf(first.definition).toEqualTypeOf< - FieldValidator - >() - expectTypeOf(first.owner).toEqualTypeOf<{ name: 'name' }>() - expectTypeOf(first.errorTargets).toEqualTypeOf | null>() - expectTypeOf(first.resolvedWatchFields).toEqualTypeOf | null>() - expectTypeOf(first.output).toEqualTypeOf() - expectTypeOf( - first.debouncer, - ).toEqualTypeOf | null>() + expect(form.state.errors).toEqual([{ message: 'Last error' }]) + expect(form.state.isValidating).toBe(true) + + pending.resolve('First change error') + await validating + + expect(form.state.errors).toEqual([ + { message: 'First change error' }, + { message: 'Last error' }, + ]) + expect(form.state.isValidating).toBe(false) }) - 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.setOutput({ - output: 'output', - hasOutput: true, - }) - instance.addErrorTarget('target') - instance.setResolvedWatchField('source', watchedField) - instance.markMountValidationRan() - - instance.updateDefinition(initialDefinition) - expect(instance.revision).toBe(1) - - const nextDefinition = createDefinition('updated') - instance.updateDefinition(nextDefinition) - - expect(instance.definition).toBe(nextDefinition) - expect(instance.revision).toBe(2) - expect(instance.abortController).toBe(abortController) - expect(instance.debouncer).toBe(debouncer) - expect(instance.output).toBe('output') - expect(instance.hasOutput).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('keeps submission outputs in their original positions when a validator is skipped', async () => { + const onSubmit = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: 'Ada' }, + validators: [ + { + skipOnSubmit: true, + run: ({ createOutput }) => createOutput('Skipped'), + }, + { + run: ({ value, createOutput }) => createOutput(value.name), + }, + { + run: ({ createOutput }) => createOutput(undefined), + }, + ], + onSubmit, + }) + + await form.handleSubmit() + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + validatorOutputs: [undefined, 'Ada', undefined], + }), + ) }) - 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('keeps form schema errors when group schema errors are cleared on group reset', async () => { + const form = new InternalFormApi({ + defaultValues: { profile: { name: '' } }, + validators: [ + { + triggers: ['change'], + run: z.object({ + profile: z + .object({ name: z.string() }) + .refine(({ name }) => name.length > 0, 'Form error'), + }), + }, + ], + }) + const group = new InternalFormGroupApi({ + form, + name: 'profile', + validators: [ + { + triggers: ['change'], + run: z + .object({ name: z.string() }) + .refine(({ name }) => name.length > 0, 'Group error'), + }, + ], + }) + onTestFinished(() => group.reset()) + + await form.validate('change') + await group.validate('change') + + expect(group.state.errors).toMatchObject([{ message: 'Group error' }]) + expect(group.state.isInvalid).toBe(true) + + group.reset() + + expect(group.state.errors).toEqual([]) + expect(group.state.isInvalid).toBe(true) + + await group.validate('change') + + expect(group.state.errors).toMatchObject([{ message: 'Group error' }]) }) - 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) => {}) + it('clears stored errors on reset and keeps validators available for later validation', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => (value.name ? undefined : 'Required'), + }, + ], + }) - const debouncer = instance.getOrCreateDebouncer(firstFn, 100) - expect(debouncer).toBeInstanceOf(LiteDebouncer) - expect(instance.debouncer).toBe(debouncer) - debouncer?.maybeExecute('first') + await form.validate('change') - const reconfigured = instance.getOrCreateDebouncer(secondFn, 200) - expect(reconfigured).toBe(debouncer) - expect(reconfigured?.fn).toBe(secondFn) - expect(reconfigured?.options.wait).toBe(200) - reconfigured?.maybeExecute('second') + expect(form.state.errors).toEqual([{ message: 'Required' }]) - await vi.advanceTimersByTimeAsync(199) - expect(firstFn).not.toHaveBeenCalled() - expect(secondFn).not.toHaveBeenCalled() + form.reset() - await vi.advanceTimersByTimeAsync(1) - expect(firstFn).not.toHaveBeenCalled() - expect(secondFn).toHaveBeenCalledOnce() - expect(secondFn).toHaveBeenCalledWith('second') + expect(form.state.errors).toEqual([]) + expect(form.state.isInvalid).toBe(false) - reconfigured?.maybeExecute('cancelled') - instance.cancelExecution() - await vi.advanceTimersByTimeAsync(200) + await form.validate('change') - expect(secondFn).toHaveBeenCalledOnce() - expect(instance.debouncer).toBeNull() + expect(form.state.errors).toEqual([{ message: 'Required' }]) }) - it('distinguishes an unset schema output from an undefined output', () => { - const instance = createInstance() - - instance.setOutput({ - output: 'ignored', - hasOutput: false, + it('aborts pending validation on reset and ignores its late result after another validation', async () => { + vi.useFakeTimers() + let validatorSignal: AbortSignal | undefined + const form = new InternalFormApi({ + defaultValues: { name: 'Ada' }, + validators: [ + { + triggers: ['change'], + run: ({ signal }) => { + validatorSignal = signal + return new Promise((resolve) => { + setTimeout(() => resolve('Discarded error'), 50) + }) + }, + }, + { triggers: ['blur'], run: () => 'Current error' }, + ], }) - expect(instance.output).toBeUndefined() - expect(instance.hasOutput).toBe(false) + onTestFinished(() => form.reset()) - instance.setOutput({ - output: undefined, - hasOutput: true, - }) - expect(instance.output).toBeUndefined() - expect(instance.hasOutput).toBe(true) + const validating = form.validate('change') - instance.clearOutput() - expect(instance.output).toBeUndefined() - expect(instance.hasOutput).toBe(false) - }) + expect(validatorSignal?.aborted).toBe(false) + expect(form.state.isValidating).toBe(true) - 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.setOutput({ - output: 'output', - hasOutput: true, - }) - instance.addErrorTarget('target') - instance.setResolvedWatchField('source', watchedField) - instance.markMountValidationRan() - debouncer?.maybeExecute('cancelled') - - instance.resetRuntime() - await vi.advanceTimersByTimeAsync(100) - - expect(abortController.signal.aborted).toBe(true) - expect(debouncedFn).not.toHaveBeenCalled() - expect(instance.abortController).toBeNull() - expect(instance.debouncer).toBeNull() - expect(instance.output).toBeUndefined() - expect(instance.hasOutput).toBe(false) - expect(instance.errorTargets).toBeNull() - expect(instance.resolvedWatchFields?.get('source')).toBe(watchedField) - expect(instance.didRunOnMount).toBe(true) - expect(instance.definition).toBe(definition) - expect(instance.owner).toBe(owner) - expect(instance.scope).toBe(validationSourceScopes.field) - expect(instance.revision).toBe(1) - expect(instance.disposed).toBe(false) - }) + form.reset() - 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.setOutput({ - output: 'output', - hasOutput: true, - }) - instance.addErrorTarget('target') - instance.setResolvedWatchField('source', { name: 'source' }) - instance.markMountValidationRan() - debouncer?.maybeExecute('cancelled') - - const onBeforeDispose = vi.fn((disposingInstance: typeof instance) => { - expect(disposingInstance).toBe(instance) - expect(disposingInstance.disposed).toBe(false) - expect(disposingInstance.errorTargets).toEqual(new Set(['target'])) - expect(disposingInstance.resolvedWatchFields?.has('source')).toBe(true) - }) + expect(validatorSignal?.aborted).toBe(true) + expect(form.state.isValidating).toBe(false) + await expect(validating).resolves.toEqual([]) - instance.dispose(onBeforeDispose) - instance.dispose(onBeforeDispose) - await vi.advanceTimersByTimeAsync(100) - - expect(onBeforeDispose).toHaveBeenCalledOnce() - expect(abortController.signal.aborted).toBe(true) - expect(debouncedFn).not.toHaveBeenCalled() - expect(instance.abortController).toBeNull() - expect(instance.debouncer).toBeNull() - expect(instance.output).toBeUndefined() - expect(instance.hasOutput).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.setOutput({ - output: 'ignored', - hasOutput: true, - }) - instance.clearOutput() - 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.hasOutput).toBe(false) - expect(instance.errorTargets).toBeNull() - expect(instance.resolvedWatchFields).toBeNull() - expect(instance.didRunOnMount).toBe(false) - expect(nextController.signal.aborted).toBe(false) - expect(nextDebouncer).toBeNull() - }) -}) + await form.validate('blur') -describe('reconcileValidatorInstances', () => { - it('normalizes missing and empty definitions to null', () => { - const owner = { name: 'form' } + expect(form.state.errors).toEqual([{ message: 'Current error' }]) - expect( - reconcileValidatorInstances({ - definitions: undefined, - instances: null, - owner, - scope: 'form', - }), - ).toBeNull() - expect( - reconcileValidatorInstances({ - definitions: [], - instances: null, - owner, - scope: 'form', - }), - ).toBeNull() + await vi.advanceTimersByTimeAsync(50) + + expect(form.state.errors).toEqual([{ message: 'Current error' }]) + expect(form.state.isValidating).toBe(false) }) - 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', + it('cancels debounced validation on reset and allows another debounced run', async () => { + vi.useFakeTimers() + const validate = vi.fn(() => 'Required') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + triggerDebounceMs: 50, + run: validate, + }, + ], }) + onTestFinished(() => form.reset()) - expect(next).toBe(initial) - expect(next).toEqual([firstInstance]) - expect(firstInstance?.definition).toBe(nextDefinition) - expect(firstInstance?.revision).toBe(1) - expect(secondInstance?.disposed).toBe(true) - }) + const canceledValidation = form.validate('change') + await vi.advanceTimersByTimeAsync(25) - 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) - }) + form.reset() - reconcileValidatorInstances({ - definitions: [createDefinition('next')], - instances: initial, - owner, - scope: 'field', - onBeforeDispose, - }) + await expect(canceledValidation).resolves.toEqual([]) + await vi.advanceTimersByTimeAsync(25) - expect(onBeforeDispose).toHaveBeenCalledWith(removedInstance) - expect(removedInstance.disposed).toBe(true) - }) + expect(validate).not.toHaveBeenCalled() + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) - it('creates added slots and disposes all slots when cleared', () => { - const owner = { name: 'group' } - const firstDefinition = createDefinition('first') - const initial = reconcileValidatorInstances({ - definitions: [firstDefinition], - instances: null, - owner, - scope: 'group', - }) - const firstInstance = initial?.[0] - const secondDefinition = createDefinition('second') - - const expanded = reconcileValidatorInstances({ - definitions: [firstDefinition, secondDefinition], - instances: initial, - owner, - scope: 'group', - }) - const secondInstance = expanded?.[1] - - expect(expanded).toBe(initial) - expect(firstInstance?.revision).toBe(1) - expect(secondInstance?.definition).toBe(secondDefinition) - expect(secondInstance?.owner).toBe(owner) - expect(secondInstance?.scope).toBe(validationSourceScopes.group) - expect(firstInstance?.index).toBe(0) - expect(secondInstance?.index).toBe(1) - - expect( - reconcileValidatorInstances({ - definitions: null, - instances: expanded, - owner, - scope: 'group', - }), - ).toBeNull() - expect(firstInstance?.disposed).toBe(true) - expect(secondInstance?.disposed).toBe(true) + const nextValidation = form.validate('change') + await vi.advanceTimersByTimeAsync(49) + + expect(validate).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + await expect(nextValidation).resolves.toEqual([{ message: 'Required' }]) + + expect(form.state.errors).toEqual([{ message: 'Required' }]) + expect(form.state.isValidating).toBe(false) }) }) diff --git a/packages/form-core/tests/devtoolsBridge.lib.spec.ts b/packages/form-core/tests/devtoolsBridge.lib.spec.ts index 8ef8f8038..23964087f 100644 --- a/packages/form-core/tests/devtoolsBridge.lib.spec.ts +++ b/packages/form-core/tests/devtoolsBridge.lib.spec.ts @@ -151,7 +151,10 @@ describe('installDevtoolsBridge', () => { defaultValues: { source: '', target: 'target' }, }) const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ name: 'target' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [{ triggers: ['change'], run: () => {} }], + }) source._register() target._register() const listener = vi.fn() @@ -167,10 +170,8 @@ describe('installDevtoolsBridge', () => { expect(fieldDependenciesChanged).toHaveBeenCalledWith([ expect.objectContaining({ - kind: 'listener', - sourceField: source, + sourceFieldName: 'source', watchingField: target, - listenerInstance: expect.anything(), }), ]) @@ -185,10 +186,8 @@ describe('installDevtoolsBridge', () => { expect(fieldDependenciesChanged).toHaveBeenCalledWith([ expect.objectContaining({ - kind: 'listener', - sourceField: source, + sourceFieldName: 'source', watchingField: target, - listenerInstance: expect.anything(), }), ]) diff --git a/packages/form-core/tests/listeners.lib.spec.ts b/packages/form-core/tests/listeners.lib.spec.ts index 60f7d77df..dd53065fc 100644 --- a/packages/form-core/tests/listeners.lib.spec.ts +++ b/packages/form-core/tests/listeners.lib.spec.ts @@ -119,7 +119,13 @@ describe('runFormListenerPipeline', () => { await expect .poll(() => consoleError.mock.calls) - .toContainEqual(expect.arrayContaining([error])) + .toEqual([ + [ + 'Listener threw an error:', + expect.objectContaining({ errors: [error] }), + ], + ]) + expect(consoleError.mock.calls[0]![1]).toBeInstanceOf(AggregateError) }) }) diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index e56627d12..4a89b1ef3 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, 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' +import { promiseWithResolvers } from './test-utils' import type { ServerValidateFailure, ServerValidateResult, @@ -61,9 +61,8 @@ describe('server validation', () => { expect(serverValidator).toHaveBeenCalledOnce() }) - it('disposes every validator once when the server pipeline rejects', async () => { + it('propagates server-trigger selection failures', async () => { const error = new Error('Pipeline failed') - const disposeSpy = vi.spyOn(InternalValidatorInstance.prototype, 'dispose') const options = formOptions({ defaultValues: { name: '' }, validators: [ @@ -80,24 +79,22 @@ describe('server validation', () => { ], }) - 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() - } + await expect(validateServerValues(options, { name: '' })).rejects.toBe( + error, + ) }) - it('rethrows validator errors after disposing their instance once', async () => { + it('waits for earlier pending validation before aggregating a thrown failure', async () => { const error = new Error('Validator failed') - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const disposeSpy = vi.spyOn(InternalValidatorInstance.prototype, 'dispose') + const pending = promiseWithResolvers() + onTestFinished(() => pending.resolve(null)) const options = formOptions({ defaultValues: { name: '' }, validators: [ + { + run: () => pending.promise, + triggers: ['server'], + }, { run: () => { throw error @@ -107,15 +104,34 @@ describe('server validation', () => { ], }) - try { - await expect(validateServerValues(options, { name: '' })).rejects.toBe( - error, - ) - expect(disposeSpy).toHaveBeenCalledOnce() - } finally { - disposeSpy.mockRestore() - consoleSpy.mockRestore() - } + const validation = validateServerValues(options, { name: '' }) + const rejection = expect(validation).rejects.toMatchObject({ + errors: [error], + }) + pending.resolve(null) + await rejection + }) + + it('propagates deferred validator failures before running a waiting bail validator', async () => { + const error = new Error('Deferred validation failed') + const pending = promiseWithResolvers() + const afterFailure = vi.fn(() => null) + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => pending.promise, triggers: ['server'] }, + { run: afterFailure, triggers: ['server'], bailIfInvalid: true }, + ], + }) + const validation = validateServerValues(options, { name: '' }) + const rejection = expect(validation).rejects.toMatchObject({ + errors: [error], + }) + + pending.reject(error) + await rejection + + expect(afterFailure).not.toHaveBeenCalled() }) it('runs server-only validators during client submit by default', async () => { @@ -138,7 +154,9 @@ describe('server validation', () => { await expect(form.validate('change')).resolves.toEqual([]) await expect(form.validate('blur')).resolves.toEqual([]) expect(serverValidator).not.toHaveBeenCalled() - await expect(form.validate('submit')).resolves.toEqual(['Server error']) + await expect(form.validate('submit')).resolves.toEqual([ + { message: 'Server error' }, + ]) expect(serverValidator).toHaveBeenCalledOnce() }) @@ -182,7 +200,9 @@ describe('server validation', () => { ) const form = new InternalFormApi(options) - await expect(form.validate('change')).resolves.toEqual(['change error']) + await expect(form.validate('change')).resolves.toEqual([ + { message: 'change error' }, + ]) expect(calls).toEqual([ { event: 'server', hasFormApi: false }, @@ -190,8 +210,18 @@ describe('server validation', () => { ]) }) + it('returns the supplied values and no outputs when no validators are configured', async () => { + const options = formOptions({ defaultValues: { name: '' } }) + const values = { name: 'Tony' } + + const validation = await validateServerValues(options, values) + const result = expectServerValidateSuccess(validation) + + expect(result.values).toBe(values) + expect(result.validatorOutputs).toEqual([]) + }) + it('returns values and schema outputs when server validation succeeds', async () => { - const setOutput = vi.spyOn(InternalValidatorInstance.prototype, 'setOutput') const options = formOptions({ defaultValues: { name: '' }, validators: [ @@ -206,17 +236,11 @@ describe('server validation', () => { ], }) - try { - const result = expectServerValidateSuccess( - await validateServerValues(options, { name: 'Tony' }), - ) + const validation = await validateServerValues(options, { name: 'Tony' }) + const result = expectServerValidateSuccess(validation) - expect(result.values).toEqual({ name: 'Tony' }) - expect(result.validatorOutputs).toEqual([{ nameLength: 4 }]) - expect(setOutput).not.toHaveBeenCalled() - } finally { - setOutput.mockRestore() - } + expect(result.values).toEqual({ name: 'Tony' }) + expect(result.validatorOutputs).toEqual([{ nameLength: 4 }]) }) it('aligns server schema outputs with all validator slots', async () => { @@ -251,6 +275,144 @@ describe('server validation', () => { ]) }) + it('aligns outputs with the complete validator list when asynchronous validators finish out of order', async () => { + const first = promiseWithResolvers() + const last = promiseWithResolvers() + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: ['change'] }, + { + run: async ({ createOutput }) => createOutput(await first.promise), + triggers: ['server'], + }, + { run: () => null, triggers: ['server'] }, + { + run: async ({ createOutput }) => createOutput(await last.promise), + triggers: ['server'], + }, + { run: () => null, triggers: ['blur'] }, + ], + }) + const validation = validateServerValues(options, { name: 'Tony' }) + + last.resolve('Last validator output') + await last.promise + first.resolve('First validator output') + const result = expectServerValidateSuccess(await validation) + + expect(result.validatorOutputs).toEqual([ + undefined, + 'First validator output', + undefined, + 'Last validator output', + undefined, + ]) + }) + + it('orders synchronous and deferred server results by their original indexes', async () => { + const first = promiseWithResolvers() + const last = promiseWithResolvers() + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: ['change'] }, + { run: () => first.promise, triggers: ['server'] }, + { + run: ({ createOutput }) => createOutput(undefined), + triggers: ['server'], + }, + { run: () => last.promise, triggers: ['server'] }, + ], + }) + const validation = validateServerValues(options, { name: '' }) + + last.resolve('Last validator error') + await last.promise + first.resolve('First validator error') + const { serverState } = expectServerValidateFailure(await validation) + + expect(serverState.validationResults).toEqual([ + { + validatorIndex: 1, + result: [{ message: 'First validator error' }], + output: null, + hasOutput: false, + }, + { validatorIndex: 2, result: null, output: undefined, hasOutput: true }, + { + validatorIndex: 3, + result: [{ message: 'Last validator error' }], + output: null, + hasOutput: false, + }, + ]) + }) + + it('omits a bail-skipped result, collects later results, and hydrates field errors at the original index', async () => { + const pending = promiseWithResolvers<{ fields: { name: string } }>() + const skipped = vi.fn(() => null) + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: ['change'] }, + { run: () => pending.promise, triggers: ['server'] }, + { run: skipped, triggers: ['server'], bailIfInvalid: true }, + { + run: ({ createOutput }) => createOutput('After bail'), + triggers: ['server'], + }, + ], + }) + const validation = validateServerValues(options, { name: '' }) + + pending.resolve({ fields: { name: 'Required' } }) + const { serverState } = expectServerValidateFailure(await validation) + + expect(skipped).not.toHaveBeenCalled() + expect(serverState.validationResults).toEqual([ + { + validatorIndex: 1, + result: { form: [], fields: { name: [{ message: 'Required' }] } }, + output: null, + hasOutput: false, + }, + { + validatorIndex: 3, + result: null, + output: 'After bail', + hasOutput: true, + }, + ]) + + const form = new InternalFormApi({ ...options, serverState }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + expect(field.errors).toEqual([{ message: 'Required' }]) + expect(form.state.canSubmit).toBe(false) + }) + + it('collects a synchronous output after a successful asynchronous bail wait', async () => { + const pending = promiseWithResolvers() + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => pending.promise, triggers: ['server'] }, + { + run: ({ createOutput }) => createOutput('Validated'), + triggers: ['server'], + bailIfInvalid: true, + }, + ], + }) + const validation = validateServerValues(options, { name: 'Tony' }) + + pending.resolve(null) + const result = expectServerValidateSuccess(await validation) + + expect(result.validatorOutputs).toEqual([undefined, 'Validated']) + }) + it('returns a serializable server state when validation fails', async () => { const options = formOptions({ defaultValues: { name: '' }, @@ -279,9 +441,9 @@ describe('server validation', () => { expect(result.serverState.validationResults[0]).toMatchObject({ validatorIndex: 0, result: { - form: 'Form error', + form: [{ message: 'Form error' }], fields: { - name: 'Name is required', + name: [{ message: 'Name is required' }], }, }, }) @@ -363,14 +525,16 @@ describe('server validation', () => { } as never) expect(form.state.values).toEqual({ name: 'Server value' }) + expect(form.defaultValues).toBe(options.defaultValues) expect(form.state.errors).toEqual([{ message: 'Server form error' }]) expect(form.state.isDirty).toBe(false) expect(form.state.isTouched).toBe(false) + expect(form.state.isDefaultValue).toBe(false) expect(form.state.submissionAttempts).toBe(1) expect(field.errors).toEqual([{ message: 'Server name error' }]) expect(field.meta.isDirty).toBe(false) expect(field.meta.isTouched).toBe(false) - expect(field.meta.isDefaultValue).toBe(true) + expect(field.meta.isDefaultValue).toBe(false) }) it('treats empty server state as an authoritative reset', async () => { @@ -422,7 +586,7 @@ describe('server validation', () => { expect(field.meta.isDefaultValue).toBe(true) }) - it('clears server errors on field events like submit errors', async () => { + it('clears server errors on change and preserves them on blur', async () => { const options = formOptions({ defaultValues: { name: '', email: '' }, validators: [ @@ -469,8 +633,8 @@ describe('server validation', () => { blurNameField.handleBlur() - expect(blurForm.state.errors).toEqual([]) - expect(blurNameField.errors).toEqual([]) + expect(blurForm.state.errors).toEqual([{ message: 'Server form error' }]) + expect(blurNameField.errors).toEqual([{ message: 'Server name error' }]) expect(blurEmailField.errors).toEqual([{ message: 'Server email error' }]) }) @@ -530,7 +694,7 @@ describe('server validation', () => { validationResults: [ { validatorIndex: 0, - result: 'Server name error', + result: [{ message: 'Server name error' }], output: null, }, ], @@ -544,35 +708,6 @@ 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, - output: undefined, - hasOutput: true, - }, - ], - submissionAttempts: 1, - }, - }) - - const form = new InternalFormApi(options) - - expect(form._validatorInstances?.[0]?.hasOutput).toBe(false) - expect(form._validatorInstances?.[0]?.output).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/serverValidate.test-d.ts b/packages/form-core/tests/serverValidate.test-d.ts index 80f2a180d..bf284d67a 100644 --- a/packages/form-core/tests/serverValidate.test-d.ts +++ b/packages/form-core/tests/serverValidate.test-d.ts @@ -6,7 +6,7 @@ import type { FieldValidators, FormApi, FormGroupValidators, - FormValidationResult, + FormattedValidationResult, ParsedStandardSchemaIssues, ServerFormState, } from '../src' @@ -55,14 +55,14 @@ describe('server validation types', () => { expectTypeOf(result.serverState).toEqualTypeOf>() expectTypeOf< (typeof result.serverState.validationResults)[number]['result'] - >().toEqualTypeOf>() + >().toEqualTypeOf>() }) it('accepts server errors independently of client validator error shapes', () => { const issue = { message: 'Invalid name', code: 400 } const serverState: ServerFormState = { values: { name: 'Tony' }, - validationResults: [{ validatorIndex: 0, result: issue, output: null }], + validationResults: [{ validatorIndex: 0, result: [issue], output: null }], submissionAttempts: 1, } diff --git a/packages/form-core/tests/standardSchema.spec.ts b/packages/form-core/tests/standardSchema.spec.ts index e7ddb793f..816761675 100644 --- a/packages/form-core/tests/standardSchema.spec.ts +++ b/packages/form-core/tests/standardSchema.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isStandardSchema } from '../src/standardSchema.lib' -import type { StandardSchemaV1 } from '../src/standardSchema.public' +import { isStandardSchema } from '../src/pipeline/validation/standardSchema.lib' +import type { StandardSchemaV1 } from '../src/validation/results/standardSchema.public' function getSchema( validate: StandardSchemaV1['~standard']['validate'], diff --git a/packages/form-core/tests/utils.test.ts b/packages/form-core/tests/utils.test.ts index be555831b..286692b47 100644 --- a/packages/form-core/tests/utils.test.ts +++ b/packages/form-core/tests/utils.test.ts @@ -22,7 +22,7 @@ describe('utils', () => { markAsTouched: true, _skipFieldCreation: false, fieldApiOverride: null, - doPropagate: true, + notifyParentFields: true, }) expect(resolveFieldUpdateOptions(undefined, 'blur')).toMatchObject({ @@ -57,7 +57,7 @@ describe('utils', () => { markAsTouched: false, _skipFieldCreation: false, fieldApiOverride, - doPropagate: false, + notifyParentFields: false, }, 'change', ), @@ -68,7 +68,7 @@ describe('utils', () => { markAsTouched: false, _skipFieldCreation: false, fieldApiOverride, - doPropagate: false, + notifyParentFields: false, }) }) diff --git a/packages/form-core/tests/validation-errors.test.ts b/packages/form-core/tests/validation-errors.test.ts index 522472593..1141442a0 100644 --- a/packages/form-core/tests/validation-errors.test.ts +++ b/packages/form-core/tests/validation-errors.test.ts @@ -1,20 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { isErrorResult, isValidationErrorMap, parseValidationResult, - reconcileRoutedFieldErrors, -} from '../src/validation' -import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' -import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' +} from '../src/validation/results/errors.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> = [ @@ -113,68 +103,3 @@ describe('parseValidationResult', () => { 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-output.test-d.ts b/packages/form-core/tests/validation-output.test-d.ts index 20f63456c..5e3f14972 100644 --- a/packages/form-core/tests/validation-output.test-d.ts +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -1,8 +1,8 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../src' -import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' -import { parseStandardSchema, validateServerValues } from '../src/internals' +import { validateServerValues } from '../src/internals' +import { parseStandardSchema } from '../src/pipeline/validation/standardSchema.lib' import type { FieldValidatorContext, FieldValidators, @@ -38,11 +38,7 @@ describe('validator output types', () => { const result = parseStandardSchema( z.object({ name: z.string() }), { name: 'Tony' }, - new InternalValidatorInstance({ - definition: { run: z.object({ name: z.string() }) }, - owner: undefined, - scope: 'form', - }), + { scope: 'form' }, ) expectTypeOf< diff --git a/packages/form-core/tests/validation-output.test.ts b/packages/form-core/tests/validation-output.test.ts index f3767a6be..894b7fc08 100644 --- a/packages/form-core/tests/validation-output.test.ts +++ b/packages/form-core/tests/validation-output.test.ts @@ -327,7 +327,12 @@ describe('validator outputs', () => { output: { name: 'Tony' }, hasOutput: true, }, - { validatorIndex: 1, result: 'Rejected', output: null, hasOutput: false }, + { + validatorIndex: 1, + result: [{ message: 'Rejected' }], + output: null, + hasOutput: false, + }, ]) }) }) diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts deleted file mode 100644 index bd3460603..000000000 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ /dev/null @@ -1,1435 +0,0 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { z } from 'zod' -import { - runFieldValidatorPipeline, - runFormValidatorPipeline, -} from '../src/validation' -import { InternalFormApi } from '../src/FormApi/FormApi.lib' -import { reconcileValidatorInstances } from '../src/ValidatorInstance.lib' -import { formOptions } from '../src' -import type { PipelineResult } from '../src/validation' -import type { - ClientSideEvents, - ClientValidationTrigger, - DeepKeys, - DeepValue, - FieldValidateResult, - FieldValidator, - FormValidationResult, - FormValidator, - FormValidatorContext, - GetValidatorOutputs, - ValidationDebounceFn, - ValidationPredicateFn, -} from '../src' -import type { AnyInternalFieldApi } from '../src/FieldApi/FieldApi.lib' -import type { StandardSchemaV1 } from '../src/standardSchema.public' - -describe('runFormValidatorPipeline', () => { - type Event = Exclude['event'], 'server'> - - function getForm(defaultValues: T) { - return new InternalFormApi({ defaultValues }) - } - - function getPipeline( - form: InternalFormApi, - pipeline: Array>, - ) { - const validatorInstances = reconcileValidatorInstances({ - definitions: pipeline, - instances: null, - owner: form, - scope: 'form', - })! - return { - pipeline: validatorInstances, - runWithContext: (args: { - event: Event - field?: AnyInternalFieldApi - onResult?: (result: PipelineResult>) => void - hasFailedBefore?: boolean - }) => { - return runFormValidatorPipeline({ - context: { - scope: 'form', - event: args.event, - formApi: form, - triggerFieldApi: args.field, - }, - hasFailedBefore: args.hasFailedBefore ?? false, - onResult: args.onResult, - pipeline: validatorInstances, - }).then((res) => res.results) - }, - } - } - - function getFieldPipeline< - TFormData, - TFieldName extends DeepKeys, - TFieldValue extends DeepValue, - >( - form: InternalFormApi, - field: AnyInternalFieldApi, - pipeline: Array>, - ) { - const validatorInstances = reconcileValidatorInstances({ - definitions: pipeline, - instances: null, - owner: field, - scope: 'field', - })! - return { - pipeline: validatorInstances, - runWithContext: (args: { - event: ClientValidationTrigger - onResult?: (result: PipelineResult) => void - }) => { - return runFieldValidatorPipeline({ - context: { - scope: 'field', - event: args.event, - formApi: form, - fieldApi: field, - }, - onResult: args.onResult, - pipeline: validatorInstances, - }).then((res) => res.results) - }, - } - } - - it('should run the pipeline with appropriate defaults', async () => { - const formApi = getForm({ name: '' }) - const { runWithContext } = getPipeline(formApi, [ - { - run: () => ({ message: 'foo' }), - triggers: [], - }, - ]) - const results = await runWithContext({ event: 'submit' }) - expect(results).toHaveLength(1) - expect(results).toContainEqual( - expect.objectContaining({ - result: { message: 'foo' }, - output: null, - }), - ) - }) - - 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)).toMatchObject({ - 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)).toMatchObject({ - 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, [ - { - run: ({ createErrorMap }) => { - const errors = createErrorMap() - errors.addFieldError('name', 'Name is required') - return errors - }, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toMatchObject([ - { result: { fields: { name: ['Name is required'] } } }, - ]) - }) - - it('should normalize returned error map helpers', async () => { - const formApi = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: ({ createErrorMap }) => { - const errors = createErrorMap() - errors.fields.name = undefined - return errors - }, - triggers: [], - }, - ], - }) - - const result = await formApi.validate('submit') - - expect(result).toEqual([]) - expect(formApi.state.errors).toEqual([]) - expect(formApi.state.canSubmit).toBe(true) - }) - - it('should route errors from returned error map helpers', async () => { - const formApi = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: ({ createErrorMap }) => { - const errors = createErrorMap() - errors.addFieldError('name', 'Name is required') - return errors - }, - triggers: [], - }, - ], - }) - const field = formApi._getOrCreateFieldApi({ name: 'name' }) - - await formApi.validate('submit') - - expect(field.errors).toEqual([{ message: 'Name is required' }]) - }) - - it('should keep plain empty objects as validation errors', async () => { - const formApi = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: () => ({}) as any, - triggers: [], - }, - ], - }) - - const result = await formApi.validate('submit') - - expect(result).toEqual([{}]) - expect(formApi.state.errors).toEqual([{}]) - expect(formApi.state.canSubmit).toBe(false) - }) - - it('should only run validators with matching signal', async () => { - const formApi = getForm({ name: '' }) - - const change = vi.fn() - const blur = vi.fn() - const blurOrChange = vi.fn() - - const { runWithContext } = getPipeline(formApi, [ - { - run: change, - triggers: ['change'], - }, - { - run: blur, - triggers: ['blur'], - }, - { - run: blurOrChange, - triggers: ['blur', 'change'], - }, - ]) - - await runWithContext({ event: 'change' }) - expect(change).toHaveBeenCalledOnce() - expect(blurOrChange).toHaveBeenCalledOnce() - - expect(blur).not.toHaveBeenCalled() - - await runWithContext({ event: 'blur' }) - expect(blur).toHaveBeenCalledOnce() - - expect(change).toHaveBeenCalledOnce() - expect(blurOrChange).toHaveBeenCalledTimes(2) - }) - - it('only runs validators on submit when skipOnSubmit allows it', async () => { - const formApi = getForm({ name: '' }) - - const runsByDefault = vi.fn() - const runsWithFalseCallback = vi.fn() - const runsWithFalse = vi.fn() - - const skipsWithTrue = vi.fn() - const skipsWithTrueCallback = vi.fn() - - const { runWithContext } = getPipeline(formApi, [ - { - run: runsByDefault, - triggers: [], - }, - { - run: runsWithFalseCallback, - skipOnSubmit: () => false, - triggers: [], - }, - { - run: runsWithFalse, - skipOnSubmit: false, - triggers: [], - }, - { - run: skipsWithTrue, - skipOnSubmit: true, - triggers: [], - }, - { - run: skipsWithTrueCallback, - skipOnSubmit: () => true, - triggers: [], - }, - ]) - - await runWithContext({ event: 'change' }) - await runWithContext({ event: 'blur' }) - expect(runsByDefault).not.toHaveBeenCalled() - expect(runsWithFalseCallback).not.toHaveBeenCalled() - expect(runsWithFalse).not.toHaveBeenCalled() - - expect(skipsWithTrue).not.toHaveBeenCalled() - expect(skipsWithTrueCallback).not.toHaveBeenCalled() - - await runWithContext({ event: 'submit' }) - expect(runsByDefault).toHaveBeenCalledOnce() - expect(runsWithFalseCallback).toHaveBeenCalledOnce() - expect(runsWithFalse).toHaveBeenCalledOnce() - - expect(skipsWithTrue).not.toHaveBeenCalled() - expect(skipsWithTrueCallback).not.toHaveBeenCalled() - }) - - it('should debounce non-submit validation', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - const run = vi.fn(() => ({ message: 'foo' })) - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - const promise = runWithContext({ event: 'change' }) - - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - - await expect(promise).resolves.toHaveLength(1) - - expect(run).toHaveBeenCalledOnce() - }) - - it('should settle as aborted when a disposed validator cannot create a debouncer', async () => { - const formApi = getForm({ name: '' }) - const run = vi.fn(() => ({ message: 'foo' })) - const onResult = vi.fn() - const { pipeline, runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - pipeline[0]!.dispose() - - await expect( - runWithContext({ event: 'change', onResult }), - ).resolves.toEqual([]) - expect(run).not.toHaveBeenCalled() - expect(onResult).not.toHaveBeenCalled() - }) - - it('should debounce validation with a function', async () => { - vi.useFakeTimers() - - const 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('field') - expect(formApi.state.values).toEqual({ name: 'test' }) - expect(fieldApi).toBe(field) - expect(value).toBe('test') - - return 100 - }) - - const { runWithContext } = getFieldPipeline(form, field, [ - { - run, - triggers: ['change'], - triggerDebounceMs, - }, - ]) - - const promise = runWithContext({ event: 'change' }) - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - - await expect(promise).resolves.toHaveLength(1) - - expect(run).toHaveBeenCalledOnce() - }) - - it('should use field value for field-level debounce functions', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: 'test' }) - const field = formApi._getOrCreateFieldApi({ name: 'name' }) - const run = vi.fn(() => ({ message: 'foo' })) - const triggerDebounceMs: ValidationDebounceFn<{ name: string }, string> = - vi.fn(({ value }) => { - expect(value).toBe('test') - - return value.length * 20 - }) - const pipeline: Array> = [ - { - run, - triggers: ['change'], - triggerDebounceMs, - }, - ] - - const { runWithContext } = getFieldPipeline(formApi, field, pipeline) - - const promise = runWithContext({ event: 'change' }) - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(79) - expect(run).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(21) - - await expect(promise).resolves.toHaveLength(1) - - expect(run).toHaveBeenCalledOnce() - }) - - it('should cancel an existing debounced validation when the same validator runs immediately', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - const run = vi.fn(() => ({ message: 'foo' })) - const onResult = vi.fn() - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - runWithContext({ event: 'change', onResult }) - - await vi.advanceTimersByTimeAsync(50) - - const submitResults = await runWithContext({ event: 'submit', onResult }) - - expect(run).toHaveBeenCalledOnce() - expect(submitResults).toHaveLength(1) - - expect(onResult).toHaveBeenCalledOnce() - expect(onResult).toHaveBeenCalledWith( - expect.objectContaining({ - result: { message: 'foo' }, - }), - ) - - await vi.advanceTimersByTimeAsync(100) - - expect(run).toHaveBeenCalledOnce() - }) - - it('should debounce separate validators independently', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - - const first = vi.fn(() => ({ message: 'first' })) - const second = vi.fn(() => ({ message: 'second' })) - - const { runWithContext } = getPipeline(formApi, [ - { - run: first, - triggers: ['change'], - triggerDebounceMs: 100, - }, - { - run: second, - triggers: ['change'], - triggerDebounceMs: 200, - }, - ]) - - const promise = runWithContext({ event: 'change' }) - - await vi.advanceTimersByTimeAsync(100) - - expect(first).toHaveBeenCalledOnce() - expect(second).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(100) - - await expect(promise).resolves.toHaveLength(2) - - expect(second).toHaveBeenCalledOnce() - }) - - it('should not debounce submit validation', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - const run = vi.fn(() => ({ message: 'foo' })) - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(run).toHaveBeenCalledOnce() - expect(results).toHaveLength(1) - }) - - it('should check for enabled triggers', async () => { - const formApi = getForm({ name: '' }) - const run = vi.fn(() => ({ message: 'foo' })) - - let doThing = false - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: [ - { trigger: 'change', when: () => doThing }, - { trigger: 'blur', when: doThing }, - ], - }, - ]) - - await runWithContext({ event: 'change' }) - await runWithContext({ event: 'blur' }) - - expect(run).not.toHaveBeenCalled() - - doThing = true - - // Since it's a function for enable, it should be the most up to date value - await runWithContext({ event: 'change' }) - expect(run).toHaveBeenCalledOnce() - // but blur should have the value stored on its own, not being updated - await runWithContext({ event: 'blur' }) - expect(run).toHaveBeenCalledOnce() - }) - - it('should use field value for field-level trigger predicates', async () => { - const formApi = getForm({ name: 'test' }) - const field = formApi._getOrCreateFieldApi({ name: 'name' }) - const run = vi.fn(() => ({ message: 'foo' })) - const when: ValidationPredicateFn<{ name: string }, string> = vi.fn( - ({ value }) => value === 'test', - ) - - const { runWithContext } = getFieldPipeline(formApi, field, [ - { - run, - triggers: [{ trigger: 'change', when }], - }, - ]) - - await runWithContext({ event: 'change' }) - - expect(when).toHaveBeenCalledOnce() - expect(when).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'field', - fieldApi: field, - value: 'test', - }), - ) - expect(run).toHaveBeenCalledOnce() - }) - - it('should abort existing debounced validation when same event runs again', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - const validationResult = { message: 'foo' } - const run = vi.fn(() => validationResult) - const onResult = vi.fn() - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - const firstPromise = runWithContext({ event: 'change', onResult }) - - await vi.advanceTimersByTimeAsync(50) - const secondPromise = runWithContext({ event: 'change', onResult }) - - await vi.advanceTimersByTimeAsync(100) - - expect(run).toHaveBeenCalledOnce() - - await firstPromise - await secondPromise - - expect(onResult).toHaveBeenCalledOnce() - expect(onResult).toHaveBeenCalledWith( - expect.objectContaining({ result: validationResult }), - ) - }) - - it('should abort existing async validation when same event runs again', async () => { - const formApi = getForm({ name: '' }) - - let resolveFirstValidation!: () => void - const waitForFirstValidation = new Promise((resolve) => { - resolveFirstValidation = resolve - }) - - const run = vi - .fn() - .mockResolvedValueOnce( - waitForFirstValidation.then(() => ({ message: 'first' })), - ) - .mockResolvedValueOnce({ message: 'second' }) - const onResult = vi.fn() - - const { runWithContext } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - }, - ]) - - const firstPromise = runWithContext({ event: 'change', onResult }) - expect(run).toHaveBeenCalledOnce() - - const secondPromise = runWithContext({ event: 'change', onResult }) - expect(run).toHaveBeenCalledTimes(2) - - resolveFirstValidation() - - await firstPromise - await secondPromise - - expect(onResult).toHaveBeenCalledOnce() - expect(onResult).toHaveBeenCalledWith( - expect.objectContaining({ result: { message: 'second' } }), - ) - }) - - it('should not call onResult for aborted debounced calls', async () => { - vi.useFakeTimers() - - const formApi = getForm({ name: '' }) - const validationResult = { message: 'foo' } - const run = vi.fn(() => validationResult) - const onResult = vi.fn() - - const { runWithContext, pipeline } = getPipeline(formApi, [ - { - run, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - const firstPromise = runWithContext({ event: 'change', onResult }) - - await vi.advanceTimersByTimeAsync(50) - const secondPromise = runWithContext({ event: 'change', onResult }) - - await vi.advanceTimersByTimeAsync(100) - - expect(run).toHaveBeenCalledOnce() - - await firstPromise - await secondPromise - - expect(onResult).toHaveBeenCalledOnce() - expect(onResult).toHaveBeenCalledWith( - expect.objectContaining({ - validatorInstance: pipeline[0], - result: validationResult, - }), - ) - }) - - it('should use the correct fieldApi when debounced validation is triggered by different fields', async () => { - vi.useFakeTimers() - - const formApi = getForm({ users: ['a', 'b', 'c'] }) - const receivedFieldApiNames: Array = [] - - const { runWithContext } = getPipeline(formApi, [ - { - run: ({ triggerFieldApi }) => { - if (triggerFieldApi) { - receivedFieldApiNames.push(triggerFieldApi.name) - } - return null - }, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - // Get or create field APIs for users[1] and users[2] - const field1 = formApi._getOrCreateFieldApi({ name: 'users[1]' }) - const field2 = formApi._getOrCreateFieldApi({ name: 'users[2]' }) - - // Trigger validation for users[1] - const firstPromise = runWithContext({ event: 'change', field: field1 }) - - // Advance time partially (before debounce completes) - await vi.advanceTimersByTimeAsync(50) - - // Trigger validation for users[2] (this should abort the first debounced call) - const secondPromise = runWithContext({ event: 'change', field: field2 }) - - // Advance time to complete the debounce - await vi.advanceTimersByTimeAsync(100) - - await firstPromise - await secondPromise - - // The validator should have been called once with users[2], not users[1] - expect(receivedFieldApiNames).toHaveLength(1) - expect(receivedFieldApiNames[0]).toBe('users[2]') - }) - - describe('Standard Schema validation', () => { - it('should infer standard schema validator output types', () => { - const lengthSchema = z - .object({ - name: z.string(), - }) - .transform(({ name }) => ({ nameLength: name.length })) - const uppercaseSchema = z - .object({ - name: z.string(), - }) - .transform(({ name }) => ({ upperName: name.toUpperCase() })) - - type Output = GetValidatorOutputs< - [ - { run: typeof lengthSchema; triggers: [] }, - { run: typeof uppercaseSchema; triggers: [] }, - ] - > - - expectTypeOf().toEqualTypeOf< - [ - { - nameLength: number - }, - { - upperName: string - }, - ] - >() - }) - - it('should infer onSubmit value from standard schema validators', () => { - const schema = z - .object({ - name: z.string(), - }) - .transform(({ name }) => ({ nameLength: name.length })) - - formOptions({ - defaultValues: { name: '' }, - validators: [{ run: schema, triggers: [] }], - onSubmit: ({ value, validatorOutputs }) => { - expectTypeOf(value).toEqualTypeOf<{ name: string }>() - expectTypeOf(validatorOutputs).toEqualTypeOf< - [{ nameLength: number }] - >() - }, - }) - - const schema2 = z - .object({ - name: z.string(), - }) - .transform(({ name }) => ({ date: new Date(name.length) })) - - formOptions({ - defaultValues: { name: '' }, - validators: [ - { run: schema, triggers: [] }, - { run: () => false, triggers: [] }, - { run: schema2, triggers: [] }, - ], - onSubmit: ({ value, validatorOutputs }) => { - expectTypeOf(value).toEqualTypeOf<{ name: string }>() - expectTypeOf(validatorOutputs).toEqualTypeOf< - [{ nameLength: number }, undefined, { date: Date }] - >() - }, - }) - }) - - it('should pass standard schema output to onSubmit', async () => { - const onSubmit = vi.fn() - const schema = z - .object({ - name: z.string(), - }) - .transform(({ name }) => ({ nameLength: name.length })) - const form = new InternalFormApi({ - defaultValues: { name: 'test' }, - validators: [{ run: schema, triggers: [] }], - onSubmit, - }) - - await form.handleSubmit() - - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - value: { name: 'test' }, - validatorOutputs: [{ nameLength: 4 }], - }), - ) - }) - - /** - * Assert that the result consists of certain fields with certain errors. - * - * { - * // ...any - * result: { - * // ...any - * fields: { - * fieldName: [] - * } - * } - * } - */ - function getFieldErrorMatcher(fields: Record>) { - const fieldMatchers = Object.fromEntries( - Object.entries(fields).map(([fieldName, messages]) => [ - fieldName, - expect.arrayContaining( - messages.map((message) => - expect.objectContaining({ - message, - }), - ), - ), - ]), - ) - - return expect.objectContaining({ - result: expect.objectContaining({ - fields: fieldMatchers, - }), - }) - } - - it('should validate form with a successful zod schema', async () => { - const formApi = getForm({ name: 'test', age: 25 }) - - const schema = z.object({ - name: z.string().min(1), - age: z.number().min(0), - }) - - const { runWithContext, pipeline } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toHaveLength(1) - expect(results[0]?.result).toBeNull() - expect(results[0]?.output).toEqual({ - name: 'test', - age: 25, - }) - expect(pipeline[0]?.hasOutput).toBe(true) - expect(pipeline[0]?.output).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]?.output).toEqual({ name: 'test' }) - expect(pipeline[0]?.hasOutput).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]?.output).toBe('TEST') - expect(pipeline[0]?.hasOutput).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]?.hasOutput).toBe(true) - expect(pipeline[0]?.output).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]?.output).toEqual({ name: 'current' }) - - resolveFirst({ value: { name: 'stale' } }) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(pipeline[0]?.output).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]?.hasOutput).toBe(true) - - shouldFail = true - await runWithContext({ event: 'submit' }) - - expect(pipeline[1]?.hasOutput).toBe(false) - expect(pipeline[1]?.output).toBeUndefined() - }) - - it('aborts pending schema output before a dynamic predicate skips the validator', async () => { - const formApi = getForm({ name: 'stale' }) - let shouldSkipOnSubmit = false - let resolveSchema!: (result: { value: { name: string } }) => void - const output = new Promise<{ value: { name: string } }>((resolve) => { - resolveSchema = resolve - }) - const schema = { - '~standard': { - version: 1, - vendor: 'test', - validate: () => output, - }, - } satisfies StandardSchemaV1<{ name: string }, { name: string }> - const { runWithContext, pipeline } = getPipeline(formApi, [ - { - run: schema, - skipOnSubmit: () => shouldSkipOnSubmit, - triggers: [], - }, - ]) - - const staleValidation = runWithContext({ event: 'submit' }) - shouldSkipOnSubmit = true - await runWithContext({ event: 'submit' }) - await staleValidation - - resolveSchema({ value: { name: 'stale' } }) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(pipeline[0]?.hasOutput).toBe(false) - expect(pipeline[0]?.output).toBeUndefined() - }) - - it('should validate form with a failing zod schema', async () => { - const formApi = getForm({ name: '', age: 25 }) - - const schema = z.object({ - name: z.string().min(1, 'Name is required'), - age: z.number().min(0), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toEqual([ - getFieldErrorMatcher({ name: ['Name is required'] }), - ]) - expect(results[0]?.output).toBeNull() - }) - - it('should validate form with multiple errors from zod schema', async () => { - const formApi = getForm({ name: '', age: -5 }) - - const schema = z.object({ - name: z.string().min(1, 'Name is required'), - age: z.number().min(0, 'Age must be positive'), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toEqual([ - getFieldErrorMatcher({ - name: ['Name is required'], - age: ['Age must be positive'], - }), - ]) - }) - - it('should validate form with nested path errors from zod schema', async () => { - const formApi = getForm({ user: { name: '', email: '' } }) - - const schema = z.object({ - user: z.object({ - name: z.string().min(1, 'Name is required'), - email: z.email('Email is invalid'), - }), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toHaveLength(1) - expect(results).toEqual([ - getFieldErrorMatcher({ - 'user.name': ['Name is required'], - 'user.email': ['Email is invalid'], - }), - ]) - }) - - it('should validate form with array path errors from zod schema', async () => { - const formApi = getForm({ users: [{ name: '' }, { name: '' }] }) - - const schema = z.object({ - users: z.array( - z.object({ - name: z.string().min(1, 'Name is required'), - }), - ), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toEqual([ - getFieldErrorMatcher({ - 'users[0].name': ['Name is required'], - 'users[1].name': ['Name is required'], - }), - ]) - }) - - it('should run zod schema validators with matching signal', async () => { - const formApi = getForm({ name: '' }) - - const schema = z.object({ - name: z.string().min(1, 'Name is required'), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: ['change'], - }, - ]) - - const blurResults = await runWithContext({ event: 'blur' }) - expect(blurResults).toHaveLength(0) - - const changeResults = await runWithContext({ event: 'change' }) - expect(changeResults).toEqual([ - getFieldErrorMatcher({ name: ['Name is required'] }), - ]) - - const submitResults = await runWithContext({ event: 'change' }) - expect(submitResults).toEqual([ - getFieldErrorMatcher({ name: ['Name is required'] }), - ]) - }) - - it('should respect bailIfInvalid with zod schema validators', async () => { - const formApi = getForm({ name: '' }) - - const failingSchema = z.object({ - name: z.string().min(1, 'Name is required'), - }) - - const passingSchema = z.object({ - name: z.string(), - }) - - const validateSpy = vi.fn(() => null) - - const { runWithContext } = getPipeline(formApi, [ - { - run: failingSchema, - triggers: [], - }, - { - run: passingSchema, - triggers: [], - bailIfInvalid: true, - }, - { - run: validateSpy, - triggers: [], - bailIfInvalid: true, - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results[0]).toEqual( - getFieldErrorMatcher({ name: ['Name is required'] }), - ) - expect(validateSpy).not.toHaveBeenCalled() - }) - - it('should work with async zod schema validation', async () => { - const formApi = getForm({ name: '' }) - - // Zod schemas can be async with refinements - const schema = z.object({ - name: z - .string() - .refine(async (val) => val.length > 0, 'Name is required'), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - }, - ]) - - const results = await runWithContext({ event: 'submit' }) - - expect(results).toEqual([ - getFieldErrorMatcher({ name: ['Name is required'] }), - ]) - }) - - it('does not run zod schema validators with skipOnSubmit: true', async () => { - const formApi = getForm({ name: '' }) - - const schema = z.object({ - name: z.string().min(1, 'Name is required'), - }) - - const { runWithContext } = getPipeline(formApi, [ - { - run: schema, - triggers: [], - skipOnSubmit: true, - }, - ]) - - const submitResults = await runWithContext({ event: 'submit' }) - expect(submitResults).toHaveLength(0) - - const changeResults = await runWithContext({ event: 'change' }) - expect(changeResults).toHaveLength(0) - }) - }) - - describe('validator errors', () => { - it('should handle validator thrown errors gracefully without bubbling', async () => { - vi.useFakeTimers() - const formApi = getForm({ name: '' }) - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) - - const { runWithContext } = getPipeline(formApi, [ - { - run: () => { - // Valid validation result - return { message: 'first validator' } - }, - triggers: [], - }, - { - run: () => { - // This will throw: Cannot read property 'foo' of undefined - const obj: any = undefined - return obj.foo.bar - }, - triggers: [], - }, - ]) - - // Should not throw - promise should resolve - const promise = runWithContext({ - event: 'submit', - hasFailedBefore: false, - }) - await vi.runAllTimersAsync() - - await expect(promise).resolves.toBeDefined() - // Should have logged the error - expect(consoleErrorSpy).toHaveBeenCalled() - - consoleErrorSpy.mockRestore() - }) - - it('should handle async validator thrown errors', async () => { - vi.useFakeTimers() - const formApi = getForm({ name: '' }) - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) - - const { runWithContext } = getPipeline(formApi, [ - { - run: async () => { - return Promise.resolve({ message: 'first validator' }) - }, - triggers: [], - }, - { - run: async () => { - await Promise.reject() - return null - }, - triggers: [], - }, - ]) - - // Should not throw - promise should resolve - const promise = runWithContext({ - event: 'submit', - hasFailedBefore: false, - }) - - await vi.runAllTimersAsync() - await expect(promise).resolves.toBeDefined() - expect(consoleErrorSpy).toHaveBeenCalled() - - consoleErrorSpy.mockRestore() - }) - - it('should handle errors in debounced validators', async () => { - vi.useFakeTimers() - const formApi = getForm({ name: '' }) - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) - - const { runWithContext } = getPipeline(formApi, [ - { - run: () => { - throw new Error('Debounced error') - }, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ]) - - const promise = runWithContext({ event: 'change' }) - - await vi.advanceTimersByTimeAsync(100) - - await expect(promise).resolves.toBeDefined() - expect(consoleErrorSpy).toHaveBeenCalled() - - consoleErrorSpy.mockRestore() - }) - }) - - describe('edge cases', () => { - it('should return empty results for killed fields', async () => { - const formApi = getForm({ name: 'test' }) - const field = formApi._getOrCreateFieldApi({ name: 'name' }) - - // Mark the field as killed - field._isKilled = true - - const pipeline: Array> = - [ - { - run: vi.fn(() => ({ message: 'should not be called' })), - triggers: ['change'], - }, - ] - - const { runWithContext } = getFieldPipeline(formApi, field, pipeline) - - const results = await runWithContext({ event: 'change' }) - - expect(results).toHaveLength(0) - }) - }) -}) diff --git a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts index 15592cba6..307ffe85f 100644 --- a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts +++ b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createErrorMap } from '../../src/validation/createErrorMap.lib' +import { createErrorMap } from '../../src/validation/results/createErrorMap.lib' describe('createErrorMap', () => { it.each([ diff --git a/packages/form-core/tests/validation/parseIssues.lib.spec.ts b/packages/form-core/tests/validation/parseIssues.lib.spec.ts index 5a865ff35..7940d444f 100644 --- a/packages/form-core/tests/validation/parseIssues.lib.spec.ts +++ b/packages/form-core/tests/validation/parseIssues.lib.spec.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - PARSED_ISSUES, - parseIssuesFactory, -} from '../../src/validation/parseIssues.lib' +import { parseIssuesFactory } from '../../src/validation/results/parseIssues.lib' describe('parseIssuesFactory - fieldErrorTarget', () => { it('routes field issues to their paths and keeps pathless issues on the form', () => { diff --git a/packages/form-core/tests/validation/state.public.test-d.ts b/packages/form-core/tests/validation/state.public.test-d.ts index 600c93404..59220613d 100644 --- a/packages/form-core/tests/validation/state.public.test-d.ts +++ b/packages/form-core/tests/validation/state.public.test-d.ts @@ -1,9 +1,9 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +import type { StandardSchemaV1Issue } from '../../src/validation/results/standardSchema.public' import type { CreateErrorMapFn, ValidationFailureItem, -} from '../../src/validation/state.public' +} from '../../src/validation/results/results.public' declare const createErrorMap: CreateErrorMapFn declare const createNameErrorMap: CreateErrorMapFn<{ name: string }> diff --git a/packages/form-devtools/tests/bridgeComposition.test.ts b/packages/form-devtools/tests/bridgeComposition.test.ts index b6231864d..9a3efd9cc 100644 --- a/packages/form-devtools/tests/bridgeComposition.test.ts +++ b/packages/form-devtools/tests/bridgeComposition.test.ts @@ -38,10 +38,8 @@ describe('form devtools bridge composition', () => { const removedFields = [{ field, previousPath: 'name' }] const dependencyChanges = [ { - kind: 'listener' as const, - sourceField: field, + sourceFieldName: 'name', watchingField: field, - listenerInstance: field._listenerInstances![0]!, }, ] diff --git a/packages/form-devtools/tests/fieldDetailsBridge.test.ts b/packages/form-devtools/tests/fieldDetailsBridge.test.ts index a58d8aec2..eb116b06c 100644 --- a/packages/form-devtools/tests/fieldDetailsBridge.test.ts +++ b/packages/form-devtools/tests/fieldDetailsBridge.test.ts @@ -2,8 +2,9 @@ import { InternalFormApi, InternalFormGroupApi, installDevtoolsBridge, + replaceSourceErrors, } from '@tanstack/form-core/internals' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { z } from 'zod' import { createFieldsController } from '../src/bridge/fields' import { createFieldIdentityController } from '../src/bridge/fields/identity' @@ -13,10 +14,8 @@ 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 { ValidationIssue, ValidatorEvents } from '@tanstack/form-core' +import type { ValidationErrorSource } from '@tanstack/form-core/internals' import type { DevtoolsFieldDetail, FieldDetailSubscriptionDescriptor, @@ -78,71 +77,71 @@ describe('field detail snapshots', () => { const [fieldCallback, fieldSchema] = field._validatorInstances! const groupSchema = group._validatorInstances![0]! const [formCallback, formSchema] = form._validatorInstances! - field._setMeta((meta) => ({ - ...meta, - _validationSourceErrors: new Map< - AnyInternalValidationSourceInstance, - ValidationSourceErrorState - >([ - [ - fieldCallback!, - { - errors: [ - { message: 'Field callback', code: 'field-code' } as never, - { - message: 'Field callback second', - code: 'field-code-2', - } as never, - ], - sourceEvent: 'change', - }, - ], - [ - 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 contributions: Array< + readonly [ + ValidationErrorSource, + { errors: Array; sourceEvent: ValidatorEvents }, + ] + > = [ + [ + fieldCallback!, + { + errors: [ + { message: 'Field callback', code: 'field-code' } as never, + { + message: 'Field callback second', + code: 'field-code-2', + } as never, + ], + sourceEvent: 'change', + }, + ], + [ + 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', + }, + ], + ] + for (const [source, { errors, sourceEvent }] of contributions) { + replaceSourceErrors(source, sourceEvent, [[field, errors]]) + } const full = getDevtoolsFieldDetail(field, subscription, identity) expect(full.state.value).toBe('Ada') @@ -311,7 +310,52 @@ describe('field detail snapshots', () => { }) describe('field detail bridge', () => { - it('streams topology changes and registers newly watched fields', async () => { + it('publishes changed provenance when a validator returns equivalent issues', () => { + const disconnect = connectTestEventBus() + onTestFinished(disconnect) + const mountedForms = createMountedFormsController() + onTestFinished(() => mountedForms.dispose()) + const fields = createFieldsController(mountedForms) + onTestFinished(() => fields.dispose()) + const uninstall = installDevtoolsBridge( + createFormDevtoolsBridge({ fields, mountedForms }), + ) + onTestFinished(uninstall) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + onTestFinished(form.mount()) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: ['change', 'blur'], run: () => 'Required' }], + }) + onTestFinished(field._register()) + const details: Array = [] + onTestFinished( + formDevtoolsEventClient.on('field-detail-changed', (event) => + details.push(event.payload), + ), + ) + const formInstanceId = mountedForms.getMountedFormsSnapshot()[0]!.instanceId + formDevtoolsEventClient.emit('field-list-subscribe', { formInstanceId }) + const fieldId = fields + .getFieldRowsSnapshot(form) + .find(({ path }) => path === 'name')!.fieldId + formDevtoolsEventClient.emit( + 'field-detail-subscribe', + descriptor(formInstanceId, fieldId), + ) + field.handleChange('value') + const errors = field.meta.original.errors + expect(details.at(-1)?.state.meta.original.errors[0]?.sourceEvent).toBe( + 'change', + ) + field.handleBlur() + expect(field.meta.original.errors).toBe(errors) + expect(details.at(-1)?.state.meta.original.errors[0]?.sourceEvent).toBe( + 'blur', + ) + }) + + it('streams topology changes and resolves watched paths when source fields appear', async () => { const disconnectEventBus = connectTestEventBus() const mountedForms = createMountedFormsController() const fields = createFieldsController(mountedForms) @@ -319,11 +363,15 @@ describe('field detail bridge', () => { createFormDevtoolsBridge({ fields, mountedForms }), ) const form = new InternalFormApi({ - defaultValues: { parent: {}, target: '' }, + defaultValues: { parent: {}, target: '', source: '' }, }) const unmountForm = form.mount() form._getOrCreateFieldApi({ name: 'parent' }) - const target = form._getOrCreateFieldApi({ name: 'target' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [{ triggers: ['change'], run: () => {} }], + validators: [{ triggers: ['change'], run: () => null }], + }) const details: Array = [] const patches: Array = [] const cleanupDetails = formDevtoolsEventClient.on( @@ -374,6 +422,12 @@ describe('field detail bridge', () => { ], }) + expect( + details.filter(({ fieldId }) => fieldId === targetId).at(-1)?.relations + .listensTo, + ).toEqual([]) + form.setFieldValue('source', 'created') + const targetRelations = details .filter(({ fieldId }) => fieldId === targetId) .at(-1)!.relations @@ -409,11 +463,15 @@ describe('field detail bridge', () => { }, ]) - target._update({ listeners: [], validators: [] }) + target._update({ + listeners: [{ triggers: ['change'], run: () => {} }], + validators: [{ triggers: ['change'], run: () => null }], + }) expect( details.filter(({ fieldId }) => fieldId === sourceId).at(-1)?.relations .listenedToBy, ).toEqual([]) + form.resetField('source') unregisterSource() await new Promise((resolve) => setTimeout(resolve, 0)) await Promise.resolve() @@ -577,16 +635,16 @@ describe('field detail bridge', () => { form.insertFieldValue('users', 0, { name: 'inserted' }) expect(source.name).toBe('users[1].name') + expect(details.at(-1)?.relations.listensTo).toEqual([]) + form.setFieldValue('users[0].name', 'inserted name') + const insertedSourceId = fields + .getFieldRowsSnapshot(form) + .find(({ path }) => path === 'users[0].name')!.fieldId + expect(insertedSourceId).not.toBe(sourceId) expect(details.at(-1)?.relations.listensTo).toEqual([ { - fieldId: sourceId, - causes: [ - { - kind: 'listener', - itemIndex: 0, - configuredPath: 'users[0].name', - }, - ], + fieldId: insertedSourceId, + causes: [{ kind: 'listener', itemIndex: 0 }], }, ]) diff --git a/packages/form-devtools/tests/fieldListBridge.test.ts b/packages/form-devtools/tests/fieldListBridge.test.ts index 6fb459b52..531152381 100644 --- a/packages/form-devtools/tests/fieldListBridge.test.ts +++ b/packages/form-devtools/tests/fieldListBridge.test.ts @@ -1,6 +1,7 @@ import { InternalFormApi, InternalFormGroupApi, + clearArray, installDevtoolsBridge, } from '@tanstack/form-core/internals' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -597,7 +598,7 @@ describe('field list bridge', () => { ], }) - patches.length = 0 + clearArray(patches) unregisterStable() fields.unmountField(stableField, 'stable') const unregisterRemounted = stableField._register() @@ -686,7 +687,7 @@ describe('field list bridge', () => { ]), ) - patches.length = 0 + clearArray(patches) child.handleChange('', { causeValidation: false }) expect(parent.meta.isDefaultValue).toBe(true) expect(child.meta.isDefaultValue).toBe(true) @@ -706,7 +707,7 @@ describe('field list bridge', () => { ]), ) - patches.length = 0 + clearArray(patches) form._update({ defaultValues: { user: { name: 'new default' } }, }) @@ -726,7 +727,7 @@ describe('field list bridge', () => { ]), ) - patches.length = 0 + clearArray(patches) parent._kill() await flushPatches() diff --git a/packages/form-devtools/tests/mountedFormsBridge.test.ts b/packages/form-devtools/tests/mountedFormsBridge.test.ts index 1b033fb7b..ded7c9a75 100644 --- a/packages/form-devtools/tests/mountedFormsBridge.test.ts +++ b/packages/form-devtools/tests/mountedFormsBridge.test.ts @@ -1,4 +1,4 @@ -import { InternalFormApi } from '@tanstack/form-core/internals' +import { InternalFormApi, clearArray } from '@tanstack/form-core/internals' import { describe, expect, it, vi } from 'vitest' import { createMountedFormsController } from '../src/bridge/forms/mountedForms' import { formDevtoolsEventClient } from '../src/eventClient.lib' @@ -58,7 +58,7 @@ describe('mounted forms bridge', () => { }) mountedForms.updateForm(form) - snapshots.length = 0 + clearArray(snapshots) formDevtoolsEventClient.emit('request-mounted-forms', {}) expect(snapshots).toEqual([['renamed-profile']]) diff --git a/packages/react-form-start/tests/start.spec.ts b/packages/react-form-start/tests/start.spec.ts index e67e7219d..1738103e9 100644 --- a/packages/react-form-start/tests/start.spec.ts +++ b/packages/react-form-start/tests/start.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'devalue' import { formOptions, initialServerFormState } from '@tanstack/form-core' +import { clearArray } from '@tanstack/form-core/internals' import { getFormData, start } from '../src' const startMocks = vi.hoisted(() => ({ @@ -58,10 +59,57 @@ const formOpts = formOptions({ describe('start server validation persistence', () => { beforeEach(() => { - startMocks.calls.length = 0 + clearArray(startMocks.calls) startMocks.cookies.clear() }) + it('waits for validator peers before rejecting without persisting or routing the exception', async ({ + onTestFinished, + }) => { + const error = new Error('Server validator failed') + const peerResult = Promise.withResolvers() + onTestFinished(() => peerResult.resolve('Peer error')) + const peer = vi.fn(() => peerResult.promise) + const onValid = vi.fn() + const onInvalid = vi.fn() + const validate = start({ onValid, onInvalid }).createServerValidate( + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['server'], + run: () => { + throw error + }, + }, + { triggers: ['server'], run: peer }, + ], + }), + ) + const onSuccess = vi.fn() + const onFailure = vi.fn() + const validation = validate({ + formData: createFormData({ name: '' }), + }) + void validation.then(onSuccess, onFailure) + + await vi.waitFor(() => expect(peer).toHaveBeenCalledOnce()) + expect(onSuccess).not.toHaveBeenCalled() + expect(onFailure).not.toHaveBeenCalled() + expect(onValid).not.toHaveBeenCalled() + expect(onInvalid).not.toHaveBeenCalled() + expect(startMocks.cookies.has(internalsCookieName)).toBe(false) + + peerResult.resolve('Peer error') + await expect(validation).rejects.toBeInstanceOf(AggregateError) + await expect(validation).rejects.toMatchObject({ errors: [error] }) + expect(onFailure).toHaveBeenCalledOnce() + expect(onSuccess).not.toHaveBeenCalled() + expect(onValid).not.toHaveBeenCalled() + expect(onInvalid).not.toHaveBeenCalled() + expect(startMocks.cookies.has(internalsCookieName)).toBe(false) + }) + it('writes invalid server state to the internal cookie', async () => { const validate = start().createServerValidate(formOpts) @@ -72,6 +120,14 @@ describe('start server validation persistence', () => { expect(result.success).toBe(false) const cookie = startMocks.cookies.get(internalsCookieName) expect(cookie).toBeDefined() + if (result.success) throw new Error('Expected invalid server state') + expect(parse(cookie!)).toEqual(result.serverState) + expect(result.serverState.validationResults[0]?.result).not.toHaveProperty( + 'addFormError', + ) + expect(result.serverState.validationResults[0]?.result).not.toHaveProperty( + 'addFieldError', + ) expect(parse(cookie!)).toMatchObject({ values: { name: '' }, submissionAttempts: 1, @@ -80,7 +136,7 @@ describe('start server validation persistence', () => { validatorIndex: 0, result: { fields: { - name: ['Name is required'], + name: [{ message: 'Name is required' }], }, }, }, From 93efaf014a552e93a3d5947827fb44e71190c0dd Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:02:40 +0100 Subject: [PATCH 14/15] fix: delete outdated file --- .../tests/FieldApi/validation.spec.ts | 420 ------------------ 1 file changed, 420 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/validation.spec.ts diff --git a/packages/form-core/tests/FieldApi/validation.spec.ts b/packages/form-core/tests/FieldApi/validation.spec.ts deleted file mode 100644 index 715dba0c2..000000000 --- a/packages/form-core/tests/FieldApi/validation.spec.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - mount validators', () => { - it('runs synchronous mount validators on first registration', () => { - const validator = vi.fn(() => 'Field is invalid') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: validator, - triggers: ['mount'], - }, - ], - }) - - const unregister = field._register() - - expect(validator).toHaveBeenCalledWith( - expect.objectContaining({ - event: 'mount', - fieldApi: field, - formApi: form, - value: '', - }), - ) - expect(field.errors).toEqual([{ message: 'Field is invalid' }]) - - unregister() - }) - - it('skips field mount validation when no validator opts in', () => { - const validator = vi.fn(() => 'Should not run') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: validator, - triggers: [], - }, - ], - }) - - const unregister = field._register() - - expect(validator).not.toHaveBeenCalled() - expect(field.meta.isValidating).toBe(false) - - unregister() - }) - - it('tracks async mount validation on the field', async () => { - let resolve!: (value: string) => void - const result = new Promise((res) => { - resolve = res - }) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => result, - triggers: ['mount'], - }, - ], - }) - - const unregister = field._register() - expect(field.meta.isValidating).toBe(true) - - resolve('Async field error') - await vi.waitFor(() => expect(field.meta.isValidating).toBe(false)) - - expect(field.errors).toEqual([{ message: 'Async field error' }]) - - unregister() - }) -}) - -describe('field - linked validators', () => { - it('runs a watched validator when its watched source field changes', async () => { - vi.useFakeTimers() - const validator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: 'target' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: validator, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - - await vi.runOnlyPendingTimersAsync() - - expect(validator).toHaveBeenCalledOnce() - expect(validator).toHaveBeenCalledWith( - expect.objectContaining({ - value: 'target', - fieldApi: targetField, - formApi: form, - }), - ) - }) - - it('does not run unrelated watched validators', async () => { - vi.useFakeTimers() - const watchedValidator = vi.fn() - const unrelatedValidator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: watchedValidator, - }, - { - triggers: ['change'], - watchFields: ['other'], - run: unrelatedValidator, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - await vi.runOnlyPendingTimersAsync() - - expect(watchedValidator).toHaveBeenCalledOnce() - expect(unrelatedValidator).not.toHaveBeenCalled() - }) - - it("does not run a watched field's validator when the trigger does not match", async () => { - vi.useFakeTimers() - const validator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['blur'], - watchFields: ['source'], - run: validator, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - await vi.runOnlyPendingTimersAsync() - - expect(validator).not.toHaveBeenCalled() - }) - - it('runs a linked validator when any of multiple watched fields changes', async () => { - vi.useFakeTimers() - const validator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source', 'other'], - run: validator, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - sourceField.handleChange('source') - otherField.handleChange('other') - await vi.runOnlyPendingTimersAsync() - - expect(validator).toHaveBeenCalledTimes(2) - }) - - it('updates watched fields when the watched name changes without moving a field', async () => { - vi.useFakeTimers() - const validator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: validator, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - targetField._update({ - validators: [ - { - triggers: ['change'], - watchFields: ['other'], - run: validator, - }, - ], - }) - - sourceField.handleChange('source changed') - await vi.runOnlyPendingTimersAsync() - - expect(validator).not.toHaveBeenCalled() - - otherField.handleChange('other changed') - await vi.runOnlyPendingTimersAsync() - - expect(validator).toHaveBeenCalledOnce() - }) - - it('updates a dynamic watched field name after its field moves', async () => { - vi.useFakeTimers() - const validator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { users: ['first', 'second'], target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['users[0]'], - run: validator, - }, - ], - }) - const fieldAt0Before = form._getOrCreateFieldApi({ name: 'users[0]' }) - const fieldAt1Before = form._getOrCreateFieldApi({ name: 'users[1]' }) - - form.swapFieldValues('users', 0, 1) - expect(form._getOrCreateFieldApi({ name: 'users[1]' })).toBe(fieldAt0Before) - - targetField._update({ - validators: [ - { - triggers: ['change'], - watchFields: ['users[1]'], - run: validator, - }, - ], - }) - - fieldAt1Before.handleChange('first index changed') - await vi.runOnlyPendingTimersAsync() - - expect(validator).not.toHaveBeenCalled() - - fieldAt0Before.handleChange('second index changed') - await vi.runOnlyPendingTimersAsync() - - expect(validator).toHaveBeenCalledOnce() - }) - - it('keeps validator result indices stable when running selected watched validators', async () => { - vi.useFakeTimers() - const otherValidator = vi.fn(() => 'other error') - const sourceValidator = vi - .fn() - .mockReturnValueOnce('source error') - .mockReturnValueOnce('updated source error') - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['other'], - run: otherValidator, - }, - { - triggers: ['change'], - watchFields: ['source'], - run: sourceValidator, - }, - ], - }) - 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._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._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() - await vi.runOnlyPendingTimersAsync() - }) - - it('warns and stops cyclical validator watch chains', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const firstValidator = vi.fn() - const secondValidator = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { first: '', second: '' }, - }) - const firstField = form._getOrCreateFieldApi({ - name: 'first', - validators: [ - { - triggers: ['change'], - watchFields: ['second'], - run: firstValidator, - }, - ], - }) - form._getOrCreateFieldApi({ - name: 'second', - validators: [ - { - triggers: ['change'], - watchFields: ['first'], - run: secondValidator, - }, - ], - }) - - firstField.handleChange('first') - await vi.runOnlyPendingTimersAsync() - - expect(firstValidator).toHaveBeenCalledOnce() - expect(secondValidator).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('cyclical validator cycle detected'), - ) - - warn.mockRestore() - }) - - it('clears watched validator links when a field is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { run: vi.fn(), watchFields: ['source'], triggers: ['change'] }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const validatorInstance = targetField._validatorInstances![0]! - - expect(sourceField._watchingValidatorFields?.has(targetField)).toBe(true) - expect(validatorInstance.resolvedWatchFields?.get('source')).toBe( - sourceField, - ) - - form.deleteField('target') - - expect(sourceField._watchingValidatorFields).toBeNull() - expect(validatorInstance.resolvedWatchFields).toBeNull() - expect(targetField._isKilled).toBe(true) - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) -}) From 789132044be426db587185f08f090ba07a32f0e5 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:49:33 +0100 Subject: [PATCH 15/15] fix: fix naming --- .../shared-form/{WithValidators.tsx => WithValidators.test-d.tsx} | 0 .../{WithoutValidators.tsx => WithoutValidators.test-d.tsx} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/react-form/tests/public/shared-form/{WithValidators.tsx => WithValidators.test-d.tsx} (100%) rename packages/react-form/tests/public/shared-form/{WithoutValidators.tsx => WithoutValidators.test-d.tsx} (100%) diff --git a/packages/react-form/tests/public/shared-form/WithValidators.tsx b/packages/react-form/tests/public/shared-form/WithValidators.test-d.tsx similarity index 100% rename from packages/react-form/tests/public/shared-form/WithValidators.tsx rename to packages/react-form/tests/public/shared-form/WithValidators.test-d.tsx diff --git a/packages/react-form/tests/public/shared-form/WithoutValidators.tsx b/packages/react-form/tests/public/shared-form/WithoutValidators.test-d.tsx similarity index 100% rename from packages/react-form/tests/public/shared-form/WithoutValidators.tsx rename to packages/react-form/tests/public/shared-form/WithoutValidators.test-d.tsx