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..36ed03b294 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,13 @@ export class InternalFormApi< this._setValidationCount((count) => Math.max(0, count - 1)) } - async _runFormValidation( + /** + * @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 & { onResult?: boolean @@ -1070,22 +1077,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 +1107,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..9246bfa491 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 signal.aborted ? ABORTED_CALL : 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..291b170b46 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 { validatorInstance, result } = await promise + const outcome = acceptResult(validatorInstance, result, accepted) + hasErrors ||= outcome.hasErrors + if (outcome.thrownError !== null) { + thrownError = outcome.thrownError } - - 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 } } +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,69 +181,110 @@ 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, + }) - if ( - validatorInstancesToRun && - !validatorInstancesToRun.has(validatorInstance) - ) { - continue - } + const runFrom = ( + start: number, + ): + | ValidatorPipelineResult + | Promise> => { + for (let index = start; index < pipeline.length; index++) { + const validatorInstance = pipeline[index]! + const validator = validatorInstance.definition - if (!shouldRunValidator(validator, context)) { - continue - } + if ( + validatorInstancesToRun && + !validatorInstancesToRun.has(validatorInstance) + ) { + continue + } + + if (!shouldRunValidator(validator, context)) { + continue + } + + if (validator.bailIfInvalid) { + if (pending.length > 0) { + return flush().then(() => runFrom(index)) + } - if (validator.bailIfInvalid) { - await flush() + 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, +/** + * 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 settleAsPromise(() => runValidatorPipelineMaybeSync(args)) +} + interface FormValidatorPipelineArgs { pipeline: ReadonlyArray context: FormInputContext @@ -236,14 +302,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 +371,36 @@ export interface FieldValidatorPipelineResult { thrownError: unknown | null } +/** Runs the form pipeline and always settles through a promise. */ +export function runFormValidatorPipeline( + args: FormValidatorPipelineArgs, +): Promise { + return settleAsPromise(() => 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 +424,10 @@ export function runFieldValidatorPipeline({ validatorInstancesToRun, }) } + +/** Runs the field pipeline and always settles through a promise. */ +export function runFieldValidatorPipeline( + args: FieldValidatorPipelineArgs, +): Promise { + 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 4212867cce..1d55fa2e4c 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -697,6 +697,61 @@ 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 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() diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx index 766f022de9..904e267446 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,129 @@ 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 ( + <> + + + {field.meta.errors.map((error) => error.message).join()} + + + ) + }} + + + ) + } + + 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'), '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').element().textContent).toBe('') + }) + + 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,