From 518fa5c89eb862aa42ad63507f70869905ccc20d Mon Sep 17 00:00:00 2001 From: Tarek Kharsa <47394961+Tarekkharsa@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:10:04 +0400 Subject: [PATCH 1/3] fix(form-core): apply synchronous validator results in the same update as the change A field with a synchronous change validator rendered twice per keystroke on v2: once for the value with isValidating true, then a microtask later for the result. Every validation step awaited, even for synchronous validators and schemas. Apply synchronous results immediately and become asynchronous only once a validator returns a promise or is debounced, the pattern mount validation already uses. Fixes #2446 --- .changeset/quick-fields-settle.md | 5 + .../form-core/src/FieldApi/FieldApi.lib.ts | 27 +- packages/form-core/src/FormApi/FormApi.lib.ts | 36 ++- packages/form-core/src/standardSchema.lib.ts | 37 ++- .../form-core/src/validation/execution.lib.ts | 106 +++++-- packages/form-core/src/validation/index.ts | 2 + .../form-core/src/validation/pipeline.lib.ts | 285 +++++++++++------- packages/react-form/tests/FormField.spec.tsx | 118 ++++++++ 8 files changed, 451 insertions(+), 165 deletions(-) create mode 100644 .changeset/quick-fields-settle.md diff --git a/.changeset/quick-fields-settle.md b/.changeset/quick-fields-settle.md new file mode 100644 index 0000000000..d433b7d06c --- /dev/null +++ b/.changeset/quick-fields-settle.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Apply synchronous validator results in the same update as the change that triggered them. A field with a synchronous change validator (a function or a Standard Schema that validates synchronously) now renders once per keystroke instead of twice. Debounced and asynchronous validators behave as before. diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 55e6e41376..b41ae92990 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1,11 +1,11 @@ import { batch, createAtom } from '@tanstack/store' -import { callUpdater, evaluate, getBy } from '../utils.lib' +import { callUpdater, evaluate, getBy, isPromiseLike } from '../utils.lib' import { clearValidationSourceErrorsFromEvent, isValidationTriggerEnabled, parseValidationResult, runFieldMountValidatorPipeline, - runFieldValidatorPipeline, + runFieldValidatorPipelineMaybeSync, setValidationSourceError, } from '../validation' import { runFieldListenerPipeline } from '../listeners.lib' @@ -784,7 +784,7 @@ export class InternalFieldApi< * @private * Runs this field's validation pipeline. */ - async _runFieldValidation( + _runFieldValidation( event: 'change' | 'blur' | 'submit', options?: { onResult?: boolean @@ -793,20 +793,20 @@ export class InternalFieldApi< }, ): Promise { if (this._isKilled) - return { + return Promise.resolve({ results: [], hasErrors: false, thrownError: null, - } + }) const validators = this._validatorInstances if (!validators) - return { + return Promise.resolve({ results: [], hasErrors: false, thrownError: null, - } + }) let finishValidation = options?._startValidation?.() if (!finishValidation) { @@ -816,8 +816,9 @@ export class InternalFieldApi< } } + let outcome: ReturnType try { - return await runFieldValidatorPipeline({ + outcome = runFieldValidatorPipelineMaybeSync({ pipeline: validators, context: { scope: 'field', @@ -831,9 +832,17 @@ export class InternalFieldApi< : undefined, validatorInstancesToRun: options?.onlyRunValidatorInstances ?? null, }) - } finally { + } catch (error) { finishValidation() + return Promise.reject(error) + } + + if (isPromiseLike(outcome)) { + return Promise.resolve(outcome).finally(finishValidation) } + + finishValidation() + return Promise.resolve(outcome) } _setValidationCount(updater: Updater): void { diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index c3b9a3e87a..f851f7e501 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -11,6 +11,7 @@ import { evaluate, getBy, getTargetField, + isPromiseLike, nameToFieldNodeSegments, resolveFieldUpdateOptions, setBy, @@ -30,7 +31,7 @@ import { parseValidationResult, reconcileRoutedFieldErrors, runFormMountValidatorPipeline, - runFormValidatorPipeline, + runFormValidatorPipelineMaybeSync, setValidationSourceError, } from '../validation' import { runFormListenerPipeline } from '../listeners.lib' @@ -1062,7 +1063,7 @@ export class InternalFormApi< this._setValidationCount((count) => Math.max(0, count - 1)) } - async _runFormValidation( + _runFormValidation( signal: ValidationTrigger, opts?: FieldApiOverrideOptions & { onResult?: boolean @@ -1070,22 +1071,21 @@ export class InternalFormApi< }, ): Promise { const pipeline = this._validatorInstances - if (!pipeline) - return { - results: [], - hasErrors: false, - thrownError: null, - } - if (pipeline.length === 0) - return { + if (!pipeline || pipeline.length === 0) + return Promise.resolve({ results: [], hasErrors: false, thrownError: null, - } + }) this._setValidationCount((count) => count + 1) + const finishValidation = () => { + this._setValidationCount((count) => Math.max(0, count - 1)) + } + + let outcome: ReturnType try { - return await runFormValidatorPipeline({ + outcome = runFormValidatorPipelineMaybeSync({ context: { scope: 'form', event: signal, @@ -1101,9 +1101,17 @@ export class InternalFormApi< ? (result) => this._processValidationResult(result, signal) : undefined, }) - } finally { - this._setValidationCount((count) => Math.max(0, count - 1)) + } catch (error) { + finishValidation() + return Promise.reject(error) } + + if (isPromiseLike(outcome)) { + return Promise.resolve(outcome).finally(finishValidation) + } + + finishValidation() + return Promise.resolve(outcome) } _setValidationCount(updater: Updater): void { diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/standardSchema.lib.ts index 0e7e7af1d5..2deed1e27c 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -1,4 +1,4 @@ -import { isNil } from './utils.lib' +import { isNil, isPromiseLike } from './utils.lib' import { createOutput } from './validationOutput.lib' import type { StandardSchemaV1, @@ -74,15 +74,40 @@ export function isStandardSchema( return '~standard' in object } +type ParsedStandardSchemaResult = + | ValidationOutput + | ParsedStandardSchemaIssues + | Array + +/** + * Validates a value with a Standard Schema, synchronously when the schema + * returns its result synchronously and asynchronously otherwise. + */ +export function parseStandardSchemaMaybeSync( + schema: StandardSchemaV1, + value: any, + scope: 'form' | 'field', +): + | ParsedStandardSchemaResult + | Promise> { + const result = schema['~standard'].validate(value) + const parse = ( + settled: Awaited, + ): ParsedStandardSchemaResult => + settled.issues + ? parseStandardSchemaIssues(settled.issues, value, scope) + : createOutput(settled.value) + + return isPromiseLike(result) + ? Promise.resolve(result).then(parse) + : parse(result) +} + export function parseStandardSchema( schema: StandardSchemaV1, value: any, scope: 'form' | 'field', -): Promise< - | ValidationOutput - | ParsedStandardSchemaIssues - | Array -> { +): Promise> { return Promise.resolve(schema['~standard'].validate(value)).then((result) => { if (!result.issues) { return createOutput(result.value) diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index b46ba54f3e..e6ab9ab9fb 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,8 +1,9 @@ import { isStandardSchema, - parseStandardSchema, parseStandardSchemaIssues, + parseStandardSchemaMaybeSync, } from '../standardSchema.lib' +import { isPromiseLike } from '../utils.lib' import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { InternalFormApi } from '../FormApi/FormApi.lib' @@ -244,18 +245,26 @@ export function shouldRunValidator( /** * Executes a validator and normalizes its result for pipeline processing. * + * Synchronous validators and schemas return their result synchronously, so it + * can be applied in the same update as the change that triggered it. * Schema and branded function outputs retain their payload and presence marker. */ -export async function executeValidator( +export function executeValidator( validator: AnyPipelineValidator, context: AnyValidatorContext, scope: 'field' | 'form', -): Promise> { +): + | ValidatorExecutionResult + | Promise> { const result = isStandardSchema(validator.run) - ? await parseStandardSchema(validator.run, context.value, scope) - : await validator.run(context) - - return normalizeValidatorResult(result as TResult) + ? parseStandardSchemaMaybeSync(validator.run, context.value, scope) + : validator.run(context) + + return isPromiseLike(result) + ? Promise.resolve(result).then((settled) => + normalizeValidatorResult(settled as TResult), + ) + : normalizeValidatorResult(result as TResult) } /** Separates explicitly branded outputs from ordinary validation returns. */ @@ -280,9 +289,14 @@ interface RunMaybeDebouncedValidatorArgs< context: InputContext onExecute: ( inputContext: ValidateContext, - ) => Promise> + ) => + | ValidatorExecutionResult + | Promise> } +export type ValidatorRunResult = + ValidatorExecutionResult | AbortedCall | ThrownError + /** * Creates a promise that resolves with the internal sentinel when aborted. * @@ -324,9 +338,7 @@ function createAbortPromise(signal: AbortSignal): { */ async function executeWithAbort( context: ValidateContext, - onExecute: ( - inputContext: ValidateContext, - ) => Promise>, + onExecute: RunMaybeDebouncedValidatorArgs['onExecute'], ): Promise | AbortedCall> { if (context.signal.aborted) { return ABORTED_CALL @@ -388,9 +400,55 @@ export function createValidatorAbortContext( } } +/** + * Runs one undebounced validator, returning its result synchronously when the + * validator returns synchronously. + * + * Starting the run aborts any earlier execution of the same instance, so a + * slower previous result cannot overwrite this one. + */ +function runValidatorNow({ + validatorInstance, + context, + onExecute, +}: RunMaybeDebouncedValidatorArgs): + ValidatorRunResult | Promise> { + validatorInstance.debouncer?.cancel() + + const { signal, cleanup } = createValidatorAbortContext(validatorInstance) + const validationContext: ValidateContext = { ...context, signal } + + let outcome: ReturnType + try { + outcome = onExecute(validationContext) + } catch (error) { + cleanup() + console.error('Validator threw an error:', error) + return { [THROWN_ERROR]: true, error } + } + + if (!isPromiseLike(outcome)) { + cleanup() + return outcome + } + + return executeWithAbort(validationContext, () => outcome).then( + (value) => { + cleanup() + return value + }, + (error: unknown): ThrownError => { + cleanup() + console.error('Validator threw an error:', error) + return { [THROWN_ERROR]: true, error } + }, + ) +} + /** * Runs one validator immediately or through its instance-owned debouncer. * + * An undebounced validator that returns synchronously settles synchronously. * Abort and thrown-error sentinels keep stale or exceptional executions out of * normal result processing. Every settlement releases its abort resources. */ @@ -398,12 +456,15 @@ export function runMaybeDebouncedValidator({ validatorInstance, context, onExecute, -}: RunMaybeDebouncedValidatorArgs): Promise< - ValidatorExecutionResult | AbortedCall | ThrownError -> { +}: RunMaybeDebouncedValidatorArgs): + ValidatorRunResult | Promise> { const validator = validatorInstance.definition const debounceMs = getValidatorDebounceMs(validator, context) + if (debounceMs <= 0) { + return runValidatorNow({ validatorInstance, context, onExecute }) + } + const { signal, cleanup } = createValidatorAbortContext(validatorInstance) const validationContext: ValidateContext = { @@ -427,13 +488,6 @@ export function runMaybeDebouncedValidator({ 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) @@ -445,16 +499,6 @@ export function runMaybeDebouncedValidator({ 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) diff --git a/packages/form-core/src/validation/index.ts b/packages/form-core/src/validation/index.ts index f8c5e0b5a6..120e35d2e4 100644 --- a/packages/form-core/src/validation/index.ts +++ b/packages/form-core/src/validation/index.ts @@ -18,7 +18,9 @@ export { isValidationTriggerEnabled } from './execution.lib' export type { InputContext, ValidateContext } from './execution.lib' export { runFieldValidatorPipeline, + runFieldValidatorPipelineMaybeSync, runFormValidatorPipeline, + runFormValidatorPipelineMaybeSync, runValidatorPipeline, } from './pipeline.lib' export type { diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index e90bc50387..d72a45b6fa 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -1,7 +1,7 @@ import { createOutput } from '../validationOutput.lib' import { createErrorMap } from '../validation.public' import { parseStandardSchemaIssues } from '../standardSchema.lib' -import { isNotNil } from '../utils.lib' +import { isNotNil, isPromiseLike } from '../utils.lib' import { isErrorResult } from './errors.lib' import { ABORTED_CALL, @@ -29,6 +29,7 @@ import type { ValidateContext, ValidateResult, ValidatorExecutionResult, + ValidatorRunResult, } from './execution.lib' export interface PipelineResult { @@ -61,71 +62,93 @@ type PendingPromises = Array< > > +interface AcceptedResults { + results: Map> + shouldCommitOutput: boolean + onResult?: (result: PipelineResult) => void +} + /** - * Accepts a batch of pending results into the pipeline's instance-keyed map. + * Accepts one settled result into the pipeline's instance-keyed map. * - * Aborted and thrown results are excluded. Submit output is committed - * before `onResult` observes the accepted result. + * Aborted results are dropped and thrown results are reported. Submit output + * is committed before `onResult` observes the accepted result. + */ +function acceptResult( + validatorInstance: AnyInternalValidatorInstance, + executionResult: ValidatorRunResult, + { results, shouldCommitOutput, onResult }: AcceptedResults, +): { hasErrors: boolean; thrownError: unknown | null } { + if (executionResult === ABORTED_CALL) { + return { hasErrors: false, thrownError: null } + } + + if ( + isNotNil(executionResult) && + typeof executionResult === 'object' && + THROWN_ERROR in executionResult + ) { + return { hasErrors: false, thrownError: executionResult.error } + } + + const publicResult: PipelineResult = { + validatorInstance, + result: executionResult.result, + output: executionResult.output, + hasOutput: executionResult.hasOutput, + } + + if (shouldCommitOutput) { + validatorInstance.setOutput(executionResult) + } + results.set(validatorInstance, publicResult) + onResult?.(publicResult) + + return { hasErrors: isErrorResult(executionResult.result), thrownError: null } +} + +/** + * Accepts a batch of pending results as each one settles. */ async function flushPendingResults( pending: PendingPromises, - results: Map>, - shouldCommitOutput: boolean, - onResult?: (result: PipelineResult) => void, + accepted: AcceptedResults, ): 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) + const { validatorInstance, result } = await promise + const outcome = acceptResult(validatorInstance, result, accepted) + hasErrors ||= outcome.hasErrors + if (outcome.thrownError !== null) { + thrownError = outcome.thrownError } - results.set(result.validatorInstance, publicResult) - onResult?.(publicResult) }), ) return { hasErrors, thrownError } } +export interface ValidatorPipelineResult { + results: Array> + hasErrors: boolean + thrownError: unknown | null +} + /** * 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. + * Validators that return synchronously are applied immediately, so a pipeline + * made only of synchronous, undebounced validators settles synchronously and + * its results land in the same update as the event that triggered it. The + * pipeline becomes asynchronous only once a validator returns a promise or is + * debounced. 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({ +export function runValidatorPipelineMaybeSync({ pipeline, context, hasFailedBefore = false, @@ -133,11 +156,8 @@ export async function runValidatorPipeline({ onResult, scope, validatorInstancesToRun = null, -}: ValidatorPipelineArgs): Promise<{ - results: Array> - hasErrors: boolean - thrownError: unknown | null -}> { +}: ValidatorPipelineArgs): + ValidatorPipelineResult | Promise> { let pending: PendingPromises = [] const results = new Map< AnyInternalValidatorInstance, @@ -145,6 +165,11 @@ export async function runValidatorPipeline({ >() const shouldCommitOutput = context.event === 'submit' && context.scope !== 'field' + const accepted: AcceptedResults = { + results, + shouldCommitOutput, + onResult, + } if (shouldCommitOutput) { pipeline.forEach((validatorInstance) => { @@ -156,67 +181,96 @@ export async function runValidatorPipeline({ let hasErrors = hasFailedBefore let thrownError: unknown | null = null - const flush = async (): Promise => { - const { hasErrors: didError, thrownError: flushedThrownError } = - await flushPendingResults(pending, results, shouldCommitOutput, onResult) + const record = (outcome: { + hasErrors: boolean + thrownError: unknown | null + }) => { + hasErrors ||= outcome.hasErrors + if (outcome.thrownError !== null) { + thrownError = outcome.thrownError + } + } + const flush = async (): Promise => { + const flushing = pending pending = [] - hasErrors ||= didError - if (flushedThrownError !== null) { - thrownError = flushedThrownError - } + record(await flushPendingResults(flushing, accepted)) } - for (const validatorInstance of pipeline) { - const validator = validatorInstance.definition + const finish = (): ValidatorPipelineResult => ({ + results: pipeline.flatMap((validatorInstance) => { + const result = results.get(validatorInstance) + return result ? [result] : [] + }), + hasErrors, + thrownError, + }) + + const runFrom = ( + start: number, + ): + | ValidatorPipelineResult + | Promise> => { + for (let index = start; index < pipeline.length; index++) { + const validatorInstance = pipeline[index]! + const validator = validatorInstance.definition - if ( - validatorInstancesToRun && - !validatorInstancesToRun.has(validatorInstance) - ) { - continue - } + if ( + validatorInstancesToRun && + !validatorInstancesToRun.has(validatorInstance) + ) { + continue + } - if (!shouldRunValidator(validator, context)) { - continue - } + if (!shouldRunValidator(validator, context)) { + continue + } - if (validator.bailIfInvalid) { - await flush() + if (validator.bailIfInvalid) { + if (pending.length > 0) { + return flush().then(() => runFrom(index)) + } + + if (hasErrors || thrownError !== null) { + break + } + } - if (hasErrors || thrownError !== null) { - break + const outcome = runMaybeDebouncedValidator({ + validatorInstance, + context, + onExecute: (ctx) => { + return executeValidator(validator, getContext(ctx), scope) + }, + }) + + if (isPromiseLike(outcome)) { + pending.push( + Promise.resolve(outcome).then((result) => ({ + validatorInstance, + result, + })), + ) + } else { + record(acceptResult(validatorInstance, outcome, accepted)) } } - const promise = runMaybeDebouncedValidator({ - validatorInstance, - context, - onExecute: (ctx) => { - return executeValidator(validator, getContext(ctx), scope) - }, - }).then< - PendingPipelineResult< - ValidatorExecutionResult | AbortedCall | ThrownError - > - >((result) => ({ - validatorInstance, - result, - })) - - pending.push(promise) + if (pending.length > 0) { + return flush().then(finish) + } + + return finish() } - await flush() + return runFrom(0) +} - return { - results: pipeline.flatMap((validatorInstance) => { - const result = results.get(validatorInstance) - return result ? [result] : [] - }), - hasErrors, - thrownError, - } +/** Runs the shared pipeline and always resolves through a promise. */ +export function runValidatorPipeline( + args: ValidatorPipelineArgs, +): Promise> { + return Promise.resolve(runValidatorPipelineMaybeSync(args)) } interface FormValidatorPipelineArgs { @@ -236,14 +290,19 @@ export interface FormValidatorPipelineResult { thrownError: unknown | null } -/** Runs the shared pipeline with form-scoped values and issue parsing. */ -export function runFormValidatorPipeline({ +/** + * Runs the shared pipeline with form-scoped values and issue parsing. + * + * Settles synchronously when every validator that runs is synchronous. + */ +export function runFormValidatorPipelineMaybeSync({ pipeline, context, onResult, hasFailedBefore, -}: FormValidatorPipelineArgs): Promise { - return runValidatorPipeline>({ +}: FormValidatorPipelineArgs): + FormValidatorPipelineResult | Promise { + return runValidatorPipelineMaybeSync>({ pipeline, context, onResult, @@ -300,27 +359,36 @@ export interface FieldValidatorPipelineResult { thrownError: unknown | null } +/** Runs the form pipeline and always resolves through a promise. */ +export function runFormValidatorPipeline( + args: FormValidatorPipelineArgs, +): Promise { + return Promise.resolve(runFormValidatorPipelineMaybeSync(args)) +} + /** * Runs the shared pipeline with field-scoped values and issue parsing. * + * Settles synchronously when every validator that runs is synchronous. * Killed fields resolve to an empty result without executing validators. */ -export function runFieldValidatorPipeline({ +export function runFieldValidatorPipelineMaybeSync({ pipeline, context, onResult, validatorInstancesToRun = null, -}: FieldValidatorPipelineArgs): Promise { +}: FieldValidatorPipelineArgs): + FieldValidatorPipelineResult | Promise { const fieldApi = context.fieldApi as AnyInternalFieldApi if (fieldApi._isKilled) - return Promise.resolve({ + return { results: [], hasErrors: false, thrownError: null, - }) + } - return runValidatorPipeline({ + return runValidatorPipelineMaybeSync({ pipeline, context, onResult, @@ -344,3 +412,10 @@ export function runFieldValidatorPipeline({ validatorInstancesToRun, }) } + +/** Runs the field pipeline and always resolves through a promise. */ +export function runFieldValidatorPipeline( + args: FieldValidatorPipelineArgs, +): Promise { + return Promise.resolve(runFieldValidatorPipelineMaybeSync(args)) +} diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx index 766f022de9..5d82c51637 100644 --- a/packages/react-form/tests/FormField.spec.tsx +++ b/packages/react-form/tests/FormField.spec.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-react' import { userEvent } from 'vitest/browser' import React, { useEffect, useState } from 'react' +import { z } from 'zod' import { useForm } from '../src' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' @@ -304,6 +305,123 @@ describe('Form fields', () => { expect(renderCounts.middle).toBeGreaterThan(initialCounts.middle) }) + describe('applies synchronous change validation in the same render as the change', () => { + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + + const tooShort = z.string().min(2, 'Too short') + + function renderWithControl(validation: 'function' | 'schema' | 'async') { + const renders = { plain: 0, checked: 0 } + + function Component() { + const form = useForm({ defaultValues: { plain: '', checked: '' } }) + return ( + <> + + {(field) => { + renders.plain++ + return ( + + ) + }} + + + value.length > 1 ? undefined : 'Too short', + }, + ] + : validation === 'schema' + ? [{ triggers: ['change'], run: tooShort }] + : [ + { + triggers: ['change'], + run: async ({ value }) => + value === 'taken' ? 'Taken' : undefined, + }, + ] + } + > + {(field) => { + renders.checked++ + return ( + + ) + }} + + + ) + } + + return { renders, Component } + } + + it('renders a field with a function validator as often as one without', async () => { + const { renders, Component } = renderWithControl('function') + const screen = await render() + await tick() + const start = { ...renders } + + await userEvent.type(screen.getByLabelText('plain'), 'abc') + await userEvent.type(screen.getByLabelText('checked'), 'abc') + await tick() + + expect(renders.checked - start.checked).toBe(renders.plain - start.plain) + expect(screen.getByTestId('errors')).toHaveTextContent('') + }) + + it('renders a field with a Standard Schema validator as often as one without', async () => { + const { renders, Component } = renderWithControl('schema') + const screen = await render() + await tick() + const start = { ...renders } + + await userEvent.type(screen.getByLabelText('plain'), 'a') + await userEvent.type(screen.getByLabelText('checked'), 'a') + await tick() + + expect(renders.checked - start.checked).toBe(renders.plain - start.plain) + expect(screen.getByTestId('errors')).toHaveTextContent('Too short') + }) + + it('still applies asynchronous validation results once they settle', async () => { + const { Component } = renderWithControl('async') + const screen = await render() + + await userEvent.type(screen.getByLabelText('checked'), 'taken') + + await expect + .poll(() => screen.getByTestId('errors').element().textContent) + .toBe('Taken') + }) + }) + it('rerenders a field when its meta changes without changing its value', async () => { const renderCounts = { first: 0, From c07ea209d88a6ea1b3c213faa40cd0978e1d8f11 Mon Sep 17 00:00:00 2001 From: Tarek Kharsa <47394961+Tarekkharsa@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:34:15 +0400 Subject: [PATCH 2/3] fix(form-core): drop a synchronous result whose run was aborted while it ran A validator that re-enters validation of its own instance aborts the outer run. The synchronous path now returns ABORTED_CALL in that case, as the async path already did, so the outer result cannot overwrite the nested one. Also assert the function validator's error after the first keystroke, check the cleared error exactly, and document _runFormValidation. Co-Authored-By: Claude Opus 5.5 --- packages/form-core/src/FormApi/FormApi.lib.ts | 6 ++++ .../form-core/src/validation/execution.lib.ts | 2 +- .../tests/validation-pipeline.test.ts | 27 ++++++++++++++++ packages/react-form/tests/FormField.spec.tsx | 32 +++++++++++-------- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index f851f7e501..36ed03b294 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -1063,6 +1063,12 @@ export class InternalFormApi< this._setValidationCount((count) => Math.max(0, count - 1)) } + /** + * @private + * Runs the form's validation pipeline. When every validator that runs is + * synchronous, results apply and the validation count settles before this + * returns. + */ _runFormValidation( signal: ValidationTrigger, opts?: FieldApiOverrideOptions & { diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index e6ab9ab9fb..9246bfa491 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -429,7 +429,7 @@ function runValidatorNow({ if (!isPromiseLike(outcome)) { cleanup() - return outcome + return signal.aborted ? ABORTED_CALL : outcome } return executeWithAbort(validationContext, () => outcome).then( diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 4212867cce..a0fdd33dd7 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -697,6 +697,33 @@ describe('runFormValidatorPipeline', () => { ) }) + it('should drop a synchronous result when the same validator re-enters while it runs', async () => { + const formApi = getForm({ name: '' }) + const onResult = vi.fn() + let reentered = false + let nested: Promise | undefined + + const { runWithContext } = getPipeline(formApi, [ + { + run: () => { + if (reentered) return { message: 'inner' } + reentered = true + nested = runWithContext({ event: 'change', onResult }) + return { message: 'outer' } + }, + triggers: ['change'], + }, + ]) + + await runWithContext({ event: 'change', onResult }) + await nested + + expect(onResult).toHaveBeenCalledOnce() + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ result: { message: 'inner' } }), + ) + }) + it('should not call onResult for aborted debounced calls', async () => { vi.useFakeTimers() diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx index 5d82c51637..904e267446 100644 --- a/packages/react-form/tests/FormField.spec.tsx +++ b/packages/react-form/tests/FormField.spec.tsx @@ -359,19 +359,21 @@ describe('Form fields', () => { {(field) => { renders.checked++ return ( - + ) }} @@ -388,12 +390,16 @@ describe('Form fields', () => { await tick() const start = { ...renders } - await userEvent.type(screen.getByLabelText('plain'), 'abc') - await userEvent.type(screen.getByLabelText('checked'), 'abc') + await userEvent.type(screen.getByLabelText('plain'), 'a') + await userEvent.type(screen.getByLabelText('checked'), 'a') + expect(screen.getByTestId('errors')).toHaveTextContent('Too short') + + await userEvent.type(screen.getByLabelText('plain'), 'bc') + await userEvent.type(screen.getByLabelText('checked'), 'bc') await tick() expect(renders.checked - start.checked).toBe(renders.plain - start.plain) - expect(screen.getByTestId('errors')).toHaveTextContent('') + expect(screen.getByTestId('errors').element().textContent).toBe('') }) it('renders a field with a Standard Schema validator as often as one without', async () => { From 04fff052747f54722a2cbf9d0a8a27eaec0e47f0 Mon Sep 17 00:00:00 2001 From: Tarek Kharsa <47394961+Tarekkharsa@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:45:56 +0400 Subject: [PATCH 3/3] fix(form-core): reject instead of throwing from the promise pipeline wrappers With the synchronous path, a throw from onResult or from getContext escaped runValidatorPipeline, runFormValidatorPipeline and runFieldValidatorPipeline instead of rejecting their promise, as it did when they were async. Route all three through one helper that turns a synchronous throw into a rejection. Co-Authored-By: Claude Opus 5.5 --- .../form-core/src/validation/pipeline.lib.ts | 24 ++++++++++++---- .../tests/validation-pipeline.test.ts | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index d72a45b6fa..291b170b46 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -266,11 +266,23 @@ export function runValidatorPipelineMaybeSync({ return runFrom(0) } -/** Runs the shared pipeline and always resolves through a promise. */ +/** + * Settles a maybe-sync run through a promise, so a synchronous throw becomes a + * rejection as it would from an async function. + */ +function settleAsPromise(run: () => T | Promise): Promise { + try { + return Promise.resolve(run()) + } catch (error) { + return Promise.reject(error) + } +} + +/** Runs the shared pipeline and always settles through a promise. */ export function runValidatorPipeline( args: ValidatorPipelineArgs, ): Promise> { - return Promise.resolve(runValidatorPipelineMaybeSync(args)) + return settleAsPromise(() => runValidatorPipelineMaybeSync(args)) } interface FormValidatorPipelineArgs { @@ -359,11 +371,11 @@ export interface FieldValidatorPipelineResult { thrownError: unknown | null } -/** Runs the form pipeline and always resolves through a promise. */ +/** Runs the form pipeline and always settles through a promise. */ export function runFormValidatorPipeline( args: FormValidatorPipelineArgs, ): Promise { - return Promise.resolve(runFormValidatorPipelineMaybeSync(args)) + return settleAsPromise(() => runFormValidatorPipelineMaybeSync(args)) } /** @@ -413,9 +425,9 @@ export function runFieldValidatorPipelineMaybeSync({ }) } -/** Runs the field pipeline and always resolves through a promise. */ +/** Runs the field pipeline and always settles through a promise. */ export function runFieldValidatorPipeline( args: FieldValidatorPipelineArgs, ): Promise { - return Promise.resolve(runFieldValidatorPipelineMaybeSync(args)) + return settleAsPromise(() => runFieldValidatorPipelineMaybeSync(args)) } diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index a0fdd33dd7..1d55fa2e4c 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -724,6 +724,34 @@ describe('runFormValidatorPipeline', () => { ) }) + it('should reject instead of throwing when onResult throws for a synchronous result', async () => { + const formApi = getForm({ name: 'test' }) + const field = formApi._getOrCreateFieldApi({ name: 'name' }) + const failure = new Error('onResult failed') + const onResult = () => { + throw failure + } + const run = () => ({ message: 'foo' }) + + const form = getPipeline(formApi, [{ run, triggers: ['change'] }]) + const fieldPipeline = getFieldPipeline(formApi, field, [ + { run, triggers: ['change'] }, + ]) + + let formValidation: Promise | undefined + let fieldValidation: Promise | undefined + expect(() => { + formValidation = form.runWithContext({ event: 'change', onResult }) + fieldValidation = fieldPipeline.runWithContext({ + event: 'change', + onResult, + }) + }).not.toThrow() + + await expect(formValidation).rejects.toBe(failure) + await expect(fieldValidation).rejects.toBe(failure) + }) + it('should not call onResult for aborted debounced calls', async () => { vi.useFakeTimers()