Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-fields-settle.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 18 additions & 9 deletions packages/form-core/src/FieldApi/FieldApi.lib.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -784,7 +784,7 @@ export class InternalFieldApi<
* @private
* Runs this field's validation pipeline.
*/
async _runFieldValidation(
_runFieldValidation(
event: 'change' | 'blur' | 'submit',
options?: {
onResult?: boolean
Expand All @@ -793,20 +793,20 @@ export class InternalFieldApi<
},
): Promise<FieldValidatorPipelineResult> {
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) {
Expand All @@ -816,8 +816,9 @@ export class InternalFieldApi<
}
}

let outcome: ReturnType<typeof runFieldValidatorPipelineMaybeSync>
try {
return await runFieldValidatorPipeline({
outcome = runFieldValidatorPipelineMaybeSync({
pipeline: validators,
context: {
scope: 'field',
Expand All @@ -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<number>): void {
Expand Down
42 changes: 28 additions & 14 deletions packages/form-core/src/FormApi/FormApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
evaluate,
getBy,
getTargetField,
isPromiseLike,
nameToFieldNodeSegments,
resolveFieldUpdateOptions,
setBy,
Expand All @@ -30,7 +31,7 @@ import {
parseValidationResult,
reconcileRoutedFieldErrors,
runFormMountValidatorPipeline,
runFormValidatorPipeline,
runFormValidatorPipelineMaybeSync,
setValidationSourceError,
} from '../validation'
import { runFormListenerPipeline } from '../listeners.lib'
Expand Down Expand Up @@ -1062,30 +1063,35 @@ 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
hasFailedBefore?: boolean
},
): Promise<FormValidatorPipelineResult> {
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<typeof runFormValidatorPipelineMaybeSync>
try {
return await runFormValidatorPipeline({
outcome = runFormValidatorPipelineMaybeSync({
context: {
scope: 'form',
event: signal,
Expand All @@ -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<number>): void {
Expand Down
37 changes: 31 additions & 6 deletions packages/form-core/src/standardSchema.lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isNil } from './utils.lib'
import { isNil, isPromiseLike } from './utils.lib'
import { createOutput } from './validationOutput.lib'
import type {
StandardSchemaV1,
Expand Down Expand Up @@ -74,15 +74,40 @@ export function isStandardSchema(
return '~standard' in object
}

type ParsedStandardSchemaResult<TOutput> =
| ValidationOutput<TOutput>
| ParsedStandardSchemaIssues<any>
| Array<StandardSchemaV1Issue>

/**
* Validates a value with a Standard Schema, synchronously when the schema
* returns its result synchronously and asynchronously otherwise.
*/
export function parseStandardSchemaMaybeSync<TOutput>(
schema: StandardSchemaV1<any, TOutput>,
value: any,
scope: 'form' | 'field',
):
| ParsedStandardSchemaResult<TOutput>
| Promise<ParsedStandardSchemaResult<TOutput>> {
const result = schema['~standard'].validate(value)
const parse = (
settled: Awaited<typeof result>,
): ParsedStandardSchemaResult<TOutput> =>
settled.issues
? parseStandardSchemaIssues(settled.issues, value, scope)
: createOutput(settled.value)

return isPromiseLike(result)
? Promise.resolve(result).then(parse)
: parse(result)
}

export function parseStandardSchema<TOutput>(
schema: StandardSchemaV1<any, TOutput>,
value: any,
scope: 'form' | 'field',
): Promise<
| ValidationOutput<TOutput>
| ParsedStandardSchemaIssues<any>
| Array<StandardSchemaV1Issue>
> {
): Promise<ParsedStandardSchemaResult<TOutput>> {
return Promise.resolve(schema['~standard'].validate(value)).then((result) => {
if (!result.issues) {
return createOutput(result.value)
Expand Down
106 changes: 75 additions & 31 deletions packages/form-core/src/validation/execution.lib.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<TResult extends ValidateResult>(
export function executeValidator<TResult extends ValidateResult>(
validator: AnyPipelineValidator,
context: AnyValidatorContext,
scope: 'field' | 'form',
): Promise<ValidatorExecutionResult<TResult>> {
):
| ValidatorExecutionResult<TResult>
| Promise<ValidatorExecutionResult<TResult>> {
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. */
Expand All @@ -280,9 +289,14 @@ interface RunMaybeDebouncedValidatorArgs<
context: InputContext
onExecute: (
inputContext: ValidateContext,
) => Promise<ValidatorExecutionResult<TResult>>
) =>
| ValidatorExecutionResult<TResult>
| Promise<ValidatorExecutionResult<TResult>>
}

export type ValidatorRunResult<TResult extends ValidateResult> =
ValidatorExecutionResult<TResult> | AbortedCall | ThrownError

/**
* Creates a promise that resolves with the internal sentinel when aborted.
*
Expand Down Expand Up @@ -324,9 +338,7 @@ function createAbortPromise(signal: AbortSignal): {
*/
async function executeWithAbort<TResult extends ValidateResult>(
context: ValidateContext,
onExecute: (
inputContext: ValidateContext,
) => Promise<ValidatorExecutionResult<TResult>>,
onExecute: RunMaybeDebouncedValidatorArgs<TResult>['onExecute'],
): Promise<ValidatorExecutionResult<TResult> | AbortedCall> {
if (context.signal.aborted) {
return ABORTED_CALL
Expand Down Expand Up @@ -388,22 +400,71 @@ 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<TResult extends ValidateResult>({
validatorInstance,
context,
onExecute,
}: RunMaybeDebouncedValidatorArgs<TResult>):
ValidatorRunResult<TResult> | Promise<ValidatorRunResult<TResult>> {
validatorInstance.debouncer?.cancel()

const { signal, cleanup } = createValidatorAbortContext(validatorInstance)
const validationContext: ValidateContext = { ...context, signal }

let outcome: ReturnType<typeof onExecute>
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 }
},
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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.
*/
export function runMaybeDebouncedValidator<TResult extends ValidateResult>({
validatorInstance,
context,
onExecute,
}: RunMaybeDebouncedValidatorArgs<TResult>): Promise<
ValidatorExecutionResult<TResult> | AbortedCall | ThrownError
> {
}: RunMaybeDebouncedValidatorArgs<TResult>):
ValidatorRunResult<TResult> | Promise<ValidatorRunResult<TResult>> {
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 = {
Expand All @@ -427,13 +488,6 @@ export function runMaybeDebouncedValidator<TResult extends ValidateResult>({
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)
Expand All @@ -445,16 +499,6 @@ export function runMaybeDebouncedValidator<TResult extends ValidateResult>({

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)
Expand Down
Loading