import { batch, createStore } from '@tanstack/store' import { isStandardSchemaValidator, standardSchemaValidators, } from './standardSchemaValidator' import { defaultFieldMeta } from './metaHelper' import { determineFieldLevelErrorSourceAndValue, evaluate, getAsyncValidatorArray, getSyncValidatorArray, isFieldInGroup, mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' import type { AnyFormGroupApi } from './FormGroupApi' import type { FieldErrorMapFromValidator, FieldInfo, FieldLikeAPI, FieldLikeApiOptions, FieldLikeMetaBase, FieldLikeOptions, FieldLikeState, ListenerCause, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, UpdateMetaOptions, ValidationCause, ValidationError, ValidationErrorMap, } from './types' import type { ReadonlyStore } from '@tanstack/store' import type { DeepKeys, DeepValue, RejectPromiseValidator } from './util-types' import type { StandardSchemaV1, TStandardSchemaValidatorValue, } from './standardSchemaValidator' import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' import type { AsyncValidator, SyncValidator, Updater } from './utils' /** * @private */ export type FieldValidateFn< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > = (props: { value: TData fieldApi: FieldApi< TParentData, TName, TData, // This is technically an edge-type; which we try to keep non-`any`, but in this case // It's referring to an inaccessible type from the field validate function inner types, so it's not a big deal any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any > }) => unknown /** * @private */ export type FieldValidateAsyncFn< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > = (options: { value: TData fieldApi: FieldApi< TParentData, TName, TData, // This is technically an edge-type; which we try to keep non-`any`, but in this case // It's referring to an inaccessible type from the field validate function inner types, so it's not a big deal any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any > signal: AbortSignal }) => unknown | Promise /** * @private */ export type FieldListenerFn< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > = (props: { value: TData fieldApi: FieldApi< TParentData, TName, TData, // This is technically an edge-type; which we try to keep non-`any`, but in this case // It's referring to an inaccessible type from the field listener function inner types, so it's not a big deal any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any > }) => void /** * @private */ export type FieldValidateOrFn< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > = | FieldValidateFn | StandardSchemaV1 /** * @private */ export type FieldAsyncValidateOrFn< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > = | FieldValidateAsyncFn | StandardSchemaV1 export interface FieldValidators< TParentData, TName extends DeepKeys, TData extends DeepValue, TOnMount extends undefined | FieldValidateOrFn, TOnChange extends undefined | FieldValidateOrFn, TOnChangeAsync extends | undefined | FieldAsyncValidateOrFn, TOnBlur extends undefined | FieldValidateOrFn, TOnBlurAsync extends | undefined | FieldAsyncValidateOrFn, TOnSubmit extends undefined | FieldValidateOrFn, TOnSubmitAsync extends | undefined | FieldAsyncValidateOrFn, TOnDynamic extends undefined | FieldValidateOrFn, TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, > { /** * An optional function, that runs on the mount event of input. */ onMount?: RejectPromiseValidator /** * An optional function, that runs on the change event of input. * * @example z.string().min(1) */ onChange?: RejectPromiseValidator /** * An optional property similar to `onChange` but async validation * * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) */ onChangeAsync?: TOnChangeAsync /** * An optional number to represent how long the `onChangeAsync` should wait before running * * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds */ onChangeAsyncDebounceMs?: number /** * An optional list of field names that should trigger this field's `onChange` and `onChangeAsync` events when its value changes */ onChangeListenTo?: DeepKeys[] /** * An optional function, that runs on the blur event of input. * * @example z.string().min(1) */ onBlur?: RejectPromiseValidator /** * An optional property similar to `onBlur` but async validation. * * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) */ onBlurAsync?: TOnBlurAsync /** * An optional number to represent how long the `onBlurAsync` should wait before running * * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds */ onBlurAsyncDebounceMs?: number /** * An optional list of field names that should trigger this field's `onBlur` and `onBlurAsync` events when its value changes */ onBlurListenTo?: DeepKeys[] /** * An optional function, that runs on the submit event of form. * * @example z.string().min(1) */ onSubmit?: RejectPromiseValidator /** * An optional property similar to `onSubmit` but async validation. * * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) */ onSubmitAsync?: TOnSubmitAsync onDynamic?: RejectPromiseValidator onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number } export interface FieldListeners< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, > { onChange?: FieldListenerFn onChangeDebounceMs?: number onBlur?: FieldListenerFn onBlurDebounceMs?: number onMount?: FieldListenerFn onUnmount?: FieldListenerFn onSubmit?: FieldListenerFn onGroupSubmit?: FieldListenerFn } interface FieldExtraOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, TOnMount extends undefined | FieldValidateOrFn, TOnChange extends undefined | FieldValidateOrFn, TOnChangeAsync extends | undefined | FieldAsyncValidateOrFn, TOnBlur extends undefined | FieldValidateOrFn, TOnBlurAsync extends | undefined | FieldAsyncValidateOrFn, TOnSubmit extends undefined | FieldValidateOrFn, TOnSubmitAsync extends | undefined | FieldAsyncValidateOrFn, TOnDynamic extends undefined | FieldValidateOrFn, TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, > { /** * A list of validators to pass to the field */ validators?: FieldValidators< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > /** * A list of listeners which attach to the corresponding events */ listeners?: FieldListeners } /** * An object type representing the options for a field in a form. */ export interface FieldOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, TOnMount extends undefined | FieldValidateOrFn, TOnChange extends undefined | FieldValidateOrFn, TOnChangeAsync extends | undefined | FieldAsyncValidateOrFn, TOnBlur extends undefined | FieldValidateOrFn, TOnBlurAsync extends | undefined | FieldAsyncValidateOrFn, TOnSubmit extends undefined | FieldValidateOrFn, TOnSubmitAsync extends | undefined | FieldAsyncValidateOrFn, TOnDynamic extends undefined | FieldValidateOrFn, TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, > extends FieldExtraOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync >, FieldLikeOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > {} export interface FieldApiOptions< in out TParentData, in out TName extends DeepKeys, in out TData extends DeepValue, in out TOnMount extends | undefined | FieldValidateOrFn, in out TOnChange extends | undefined | FieldValidateOrFn, in out TOnChangeAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnBlur extends | undefined | FieldValidateOrFn, in out TOnBlurAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnSubmit extends | undefined | FieldValidateOrFn, in out TOnSubmitAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnDynamic extends | undefined | FieldValidateOrFn, in out TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnBlur extends undefined | FormValidateOrFn, in out TFormOnBlurAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnSubmit extends undefined | FormValidateOrFn, in out TFormOnSubmitAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnDynamic extends undefined | FormValidateOrFn, in out TFormOnDynamicAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, > extends FieldLikeApiOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta >, FieldExtraOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > {} /** * @public * * A type representing the Field API with all generics set to `any` for convenience. */ export type AnyFieldApi = FieldApi< any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any > /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. */ /** * A class representing the API for managing a form field. * * Normally, you will not need to create a new `FieldApi` instance directly. * Instead, you will use a framework hook/function like `useField` or `createField` * to create a new instance for you that uses your framework's reactivity model. * However, if you need to create a new instance manually, you can do so by calling * the `new FieldApi` constructor. */ export class FieldApi< in out TParentData, in out TName extends DeepKeys, in out TData extends DeepValue, in out TOnMount extends | undefined | FieldValidateOrFn, in out TOnChange extends | undefined | FieldValidateOrFn, in out TOnChangeAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnBlur extends | undefined | FieldValidateOrFn, in out TOnBlurAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnSubmit extends | undefined | FieldValidateOrFn, in out TOnSubmitAsync extends | undefined | FieldAsyncValidateOrFn, in out TOnDynamic extends | undefined | FieldValidateOrFn, in out TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnBlur extends undefined | FormValidateOrFn, in out TFormOnBlurAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnSubmit extends undefined | FormValidateOrFn, in out TFormOnSubmitAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnDynamic extends undefined | FormValidateOrFn, in out TFormOnDynamicAsync extends | undefined | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, > implements FieldLikeAPI< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta, FieldExtraOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > > { /** * A reference to the form API instance. */ form: FieldApiOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta >['form'] /** * The field name. */ name: TName /** * The field options. */ options: FieldApiOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta > = {} as any /** * The field state store. */ store!: ReadonlyStore< FieldLikeState< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync > > /** * The current field state. */ get state() { return this.store.state } timeoutIds: { validations: Record | null> listeners: Record | null> formListeners: Record | null> } /** * Initializes a new `FieldApi` instance. */ constructor( opts: FieldApiOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta >, ) { this.form = opts.form this.name = opts.name this.options = opts this.timeoutIds = { validations: {} as Record, listeners: {} as Record, formListeners: {} as Record, } this.store = createStore( ( prevVal: | FieldLikeState< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync > | undefined, ) => { // Temp hack to subscribe to form.store this.form.store.get() const meta = this.form.getFieldMeta(this.name) ?? { ...defaultFieldMeta, ...opts.defaultMeta, } let value = this.form.getFieldValue(this.name) if ( !meta.isTouched && (value as unknown) === undefined && this.options.defaultValue !== undefined && !evaluate(value, this.options.defaultValue) ) { value = this.options.defaultValue } if (prevVal && prevVal.value === value && prevVal.meta === meta) { return prevVal } return { value, meta, } as FieldLikeState< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync > }, ) } /** * @private */ runValidator< TValue extends TStandardSchemaValidatorValue & { fieldApi: AnyFieldApi }, TType extends 'validate' | 'validateAsync', >(props: { validate: TType extends 'validate' ? FieldValidateOrFn : FieldAsyncValidateOrFn value: TValue type: TType // When `api` is 'field', the return type cannot be `FormValidationError` }): unknown { if (isStandardSchemaValidator(props.validate)) { return standardSchemaValidators[props.type]( props.value, props.validate, ) as never } return (props.validate as FieldValidateFn)(props.value) as never } /** * Mounts the field instance to the form. * @returns A function to unmount the field instance. */ mount = () => { if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) { this.form.setFieldValue(this.name, this.options.defaultValue, { dontUpdateMeta: true, }) } const info = this.getInfo() info.instance = this as never this.update(this.options as never) const { onMount } = this.options.validators || {} if (onMount) { const error = this.runValidator({ validate: onMount, value: { value: this.state.value, fieldApi: this, validationSource: 'field', }, type: 'validate', }) if (error) { this.setMeta( (prev) => ({ ...prev, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition errorMap: { ...prev?.errorMap, onMount: error }, errorSourceMap: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ...prev?.errorSourceMap, onMount: 'field', }, }) as never, ) } } this.options.listeners?.onMount?.({ value: this.state.value, fieldApi: this, }) return () => { // Stop any in-flight async validation or listener work tied to this instance. for (const [key, timeout] of Object.entries( this.timeoutIds.validations, )) { if (timeout) { clearTimeout(timeout) this.timeoutIds.validations[ key as keyof typeof this.timeoutIds.validations ] = null } } for (const [key, timeout] of Object.entries(this.timeoutIds.listeners)) { if (timeout) { clearTimeout(timeout) this.timeoutIds.listeners[ key as keyof typeof this.timeoutIds.listeners ] = null } } for (const [key, timeout] of Object.entries( this.timeoutIds.formListeners, )) { if (timeout) { clearTimeout(timeout) this.timeoutIds.formListeners[ key as keyof typeof this.timeoutIds.formListeners ] = null } } const fieldInfo = this.form.fieldInfo[this.name] if (!fieldInfo) return // If a newer field instance has already been mounted for this name, // avoid touching its shared validation state during teardown. if (fieldInfo.instance !== this) return for (const [key, validationMeta] of Object.entries( fieldInfo.validationMetaMap, )) { validationMeta?.lastAbortController.abort() fieldInfo.validationMetaMap[ key as keyof typeof fieldInfo.validationMetaMap ] = undefined } this.form.baseStore.setState((prev) => ({ // Preserve interaction flags so field-level defaultValue does not // reseed user-entered values on remount. ...prev, fieldMetaBase: { ...prev.fieldMetaBase, [this.name]: { ...defaultFieldMeta, isTouched: prev.fieldMetaBase[this.name]?.isTouched ?? defaultFieldMeta.isTouched, isBlurred: prev.fieldMetaBase[this.name]?.isBlurred ?? defaultFieldMeta.isBlurred, isDirty: prev.fieldMetaBase[this.name]?.isDirty ?? defaultFieldMeta.isDirty, }, }, })) fieldInfo.instance = null this.options.listeners?.onUnmount?.({ value: this.state.value, fieldApi: this, }) this.form.options.listeners?.onFieldUnmount?.({ formApi: this.form, fieldApi: this, }) } } /** * Updates the field instance with new options. */ update = ( opts: FieldApiOptions< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta >, ) => { this.options = opts this.name = opts.name // Default Value if (!this.state.meta.isTouched && this.options.defaultValue !== undefined) { const formField = this.form.getFieldValue(this.name) if (!evaluate(formField, opts.defaultValue)) { this.form.setFieldValue(this.name, opts.defaultValue as never, { dontUpdateMeta: true, dontValidate: true, dontRunListeners: true, }) } } if (!this.form.getFieldMeta(this.name)) { this.form.setFieldMeta(this.name, this.state.meta) } } /** * Gets the current field value. * @deprecated Use `field.state.value` instead. */ getValue = (): TData => { return this.form.getFieldValue(this.name) as TData } /** * Sets the field value and run the `change` validator. */ setValue = (updater: Updater, options?: UpdateMetaOptions) => { this.form.setFieldValue( this.name, updater as never, mergeOpts(options, { dontRunListeners: true, dontValidate: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } if (!options?.dontValidate) { this.validate('change') } } getMeta = () => this.store.state.meta /** * Sets the field metadata. */ setMeta = ( updater: Updater< FieldLikeMetaBase< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TFormOnMount, TFormOnChange, TFormOnChangeAsync, TFormOnBlur, TFormOnBlurAsync, TFormOnSubmit, TFormOnSubmitAsync, TFormOnDynamic, TFormOnDynamicAsync > >, ) => this.form.setFieldMeta(this.name, updater) /** * Gets the field information object. */ getInfo = () => this.form.getFieldInfo(this.name) /** * Pushes a new value to the field. */ pushValue = ( value: TData extends any[] ? TData[number] : never, options?: UpdateMetaOptions, ) => { this.form.pushFieldValue( this.name, value as any, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Inserts a value at the specified index, shifting the subsequent values to the right. */ insertValue = ( index: number, value: TData extends any[] ? TData[number] : never, options?: UpdateMetaOptions, ) => { this.form.insertFieldValue( this.name, index, value as any, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Replaces a value at the specified index. */ replaceValue = ( index: number, value: TData extends any[] ? TData[number] : never, options?: UpdateMetaOptions, ) => { this.form.replaceFieldValue( this.name, index, value as any, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Removes a value at the specified index. */ removeValue = (index: number, options?: UpdateMetaOptions) => { this.form.removeFieldValue( this.name, index, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Swaps the values at the specified indices. */ swapValues = ( aIndex: number, bIndex: number, options?: UpdateMetaOptions, ) => { this.form.swapFieldValues( this.name, aIndex, bIndex, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Moves the value at the first specified index to the second specified index. */ moveValue = (aIndex: number, bIndex: number, options?: UpdateMetaOptions) => { this.form.moveFieldValues( this.name, aIndex, bIndex, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * Clear all values from the array. */ clearValues = (options?: UpdateMetaOptions) => { this.form.clearFieldValues( this.name, mergeOpts(options, { dontRunListeners: true }), ) if (!options?.dontRunListeners) { this.triggerOnChangeListener() } } /** * @private */ getLinkedFields = (cause: ValidationCause) => { const fields = Object.values(this.form.fieldInfo) as FieldInfo[] const linkedFields: AnyFieldApi[] = [] for (const field of fields) { if (!field.instance) continue // TODO: How to handle FieldGroups? Do we need to? IDK. if (!(field.instance instanceof FieldApi)) { continue } const { onChangeListenTo, onBlurListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { linkedFields.push(field.instance) } if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) { linkedFields.push(field.instance) } } return linkedFields } /** * @private */ validateSync = ( cause: ValidationCause, errorFromForm: ValidationErrorMap, ) => { const validates = getSyncValidatorArray(cause, { ...this.options, form: this.form, fieldName: this.name, validationLogic: this.form.options.validationLogic || defaultValidationLogic, }) const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( (acc, field) => { const fieldValidates = getSyncValidatorArray(cause, { ...field.options, form: field.form, fieldName: field.name, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) fieldValidates.forEach((validate) => { ;(validate as any).field = field }) return acc.concat(fieldValidates as never) }, [] as Array< SyncValidator & { field: AnyFieldApi } >, ) // Needs type cast as eslint errantly believes this is always falsy let hasErrored = false as boolean batch(() => { const validateFieldFn = ( field: AnyFieldApi, validateObj: SyncValidator, ) => { const errorMapKey = getErrorMapKey(validateObj.cause) const fieldLevelError = validateObj.validate ? normalizeError( field.runValidator({ validate: validateObj.validate, value: { value: field.store.state.value, validationSource: 'field', fieldApi: field, }, type: 'validate', }), ) : undefined const formLevelError = errorFromForm[errorMapKey] const { newErrorValue, newSource } = determineFieldLevelErrorSourceAndValue({ formLevelError, fieldLevelError, }) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (field.state.meta.errorMap?.[errorMapKey] !== newErrorValue) { field.setMeta((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: newErrorValue, }, errorSourceMap: { ...prev.errorSourceMap, [errorMapKey]: newSource, }, })) } if (newErrorValue) { hasErrored = true } } for (const validateObj of validates) { validateFieldFn(this, validateObj) } for (const fieldValitateObj of linkedFieldValidates) { if (!fieldValitateObj.validate) continue validateFieldFn(fieldValitateObj.field, fieldValitateObj) } }) /** * when we have an error for onSubmit in the state, we want * to clear the error as soon as the user enters a valid value in the field */ const submitErrKey = getErrorMapKey('submit') if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this.state.meta.errorMap?.[submitErrKey] && cause !== 'submit' && !hasErrored ) { this.setMeta((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [submitErrKey]: undefined, }, errorSourceMap: { ...prev.errorSourceMap, [submitErrKey]: undefined, }, })) } return { hasErrored } } /** * `@private` * Starts tracking an async validation, incrementing the counter and setting isValidating if needed. */ private startValidation() { this.setMeta((prev) => { const newCount = prev._pendingValidationsCount + 1 return { ...prev, _pendingValidationsCount: newCount, isValidating: newCount > 0 && !prev.isValidating ? true : prev.isValidating, } }) } /** * `@private` * Ends tracking an async validation, decrementing the counter and clearing isValidating if no validations remain. */ private endValidation() { this.setMeta((prev) => { const newCount = Math.max(0, prev._pendingValidationsCount - 1) return { ...prev, _pendingValidationsCount: newCount, isValidating: newCount === 0 && prev.isValidating ? false : prev.isValidating, } }) } /** * @private */ validateAsync = async ( cause: ValidationCause, formValidationResultPromise: Promise< FieldErrorMapFromValidator< TParentData, TName, TData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync > >, ) => { const validates = getAsyncValidatorArray(cause, { ...this.options, form: this.form, fieldName: this.name, validationLogic: this.form.options.validationLogic || defaultValidationLogic, }) // Get the field-specific error messages that are coming from the form's validator const asyncFormValidationResults = await formValidationResultPromise const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( (acc, field) => { const fieldValidates = getAsyncValidatorArray(cause, { ...field.options, form: field.form, fieldName: field.name, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) fieldValidates.forEach((validate) => { ;(validate as any).field = field }) return acc.concat(fieldValidates as never) }, [] as Array< AsyncValidator & { field: AnyFieldApi } >, ) /** * We have to use a for loop and generate our promises this way, otherwise it won't be sync * when there are no validators needed to be run */ const validatesPromises: Promise[] = [] const linkedPromises: Promise[] = [] // Check if there are actual async validators to run before setting isValidating // This prevents unnecessary re-renders when there are no async validators // See: https://github.com/TanStack/form/issues/1130 const hasAsyncValidators = validates.some((v) => v.validate) const linkedFieldsWithAsyncValidators = Array.from( new Set( linkedFieldValidates.filter((v) => v.validate).map((v) => v.field), ), ) batch(() => { if (hasAsyncValidators) { this.startValidation() } for (const linkedField of linkedFieldsWithAsyncValidators) { linkedField.startValidation() } }) const validateFieldAsyncFn = ( field: AnyFieldApi, validateObj: AsyncValidator, promises: Promise[], ) => { const errorMapKey = getErrorMapKey(validateObj.cause) const fieldInfo = field.getInfo() const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey] fieldValidatorMeta?.lastAbortController.abort() const controller = new AbortController() fieldInfo.validationMetaMap[errorMapKey] = { lastAbortController: controller, } promises.push( new Promise(async (resolve) => { let rawError!: ValidationError | undefined try { rawError = await new Promise((rawResolve, rawReject) => { if (field.timeoutIds.validations[validateObj.cause]) { clearTimeout(field.timeoutIds.validations[validateObj.cause]!) field.endValidation() } field.timeoutIds.validations[validateObj.cause] = setTimeout( async () => { if (controller.signal.aborted) return rawResolve(undefined) try { rawResolve( await this.runValidator({ validate: validateObj.validate, value: { value: field.store.state.value, fieldApi: field, signal: controller.signal, validationSource: 'field', }, type: 'validateAsync', }), ) } catch (e) { rawReject(e) } }, validateObj.debounceMs, ) }) } catch (e: unknown) { rawError = e as ValidationError } if (controller.signal.aborted) return resolve(undefined) const fieldLevelError = normalizeError(rawError) const formLevelError = asyncFormValidationResults[ field.name as keyof typeof asyncFormValidationResults ]?.[errorMapKey] const { newErrorValue, newSource } = determineFieldLevelErrorSourceAndValue({ formLevelError, fieldLevelError, }) if (field.getInfo().instance !== field) { return resolve(undefined) } field.setMeta((prev) => { return { ...prev, errorMap: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ...prev?.errorMap, [errorMapKey]: newErrorValue, }, errorSourceMap: { ...prev.errorSourceMap, [errorMapKey]: newSource, }, } }) resolve(newErrorValue) }), ) } // TODO: Dedupe this logic to reduce bundle size for (const validateObj of validates) { if (!validateObj.validate) continue validateFieldAsyncFn(this, validateObj, validatesPromises) } for (const fieldValitateObj of linkedFieldValidates) { if (!fieldValitateObj.validate) continue validateFieldAsyncFn( fieldValitateObj.field, fieldValitateObj, linkedPromises, ) } let results: ValidationError[] = [] if (validatesPromises.length || linkedPromises.length) { results = await Promise.all(validatesPromises) await Promise.all(linkedPromises) } // Only reset isValidating if we set it to true earlier batch(() => { if (hasAsyncValidators) { this.endValidation() } for (const linkedField of linkedFieldsWithAsyncValidators) { linkedField.endValidation() } }) return results.filter(Boolean) } /** * Validates the field value. */ validate = ( cause: ValidationCause, opts?: { skipFormValidation?: boolean; skipGroupValidation?: boolean }, ): ValidationError[] | Promise => { // If the field is pristine, do not validate if (!this.state.meta.isTouched) return [] // Cascade into any encompassing `FormGroupApi`'s own validators so // group-scoped strategies (e.g. `revalidateLogic` gated on the group's // own `submissionAttempts`) get a chance to react to this field change. // Mirror the field's sync→short-circuit-on-error→async semantics for // each group: only kick off async validators if the group's sync pass // was clean (or its `asyncAlways` flag is set). const encompassingGroups = opts?.skipGroupValidation ? [] : Array.from(this.form.formGroupApis).filter((group) => this.name.startsWith(group.name), ) // Attempt to sync validate first const formSyncResult = opts?.skipFormValidation ? { fieldsErrorMap: {} as never } : this.form.validateSync(cause) let fieldsErrorMap = (formSyncResult.fieldsErrorMap[this.name] ?? {}) as ValidationErrorMap // For each encompassing group whose own submission has been attempted, // also re-run the parent form's validators with that group as the // gating context. This ensures form-level errors (e.g. those produced // by a form-level z.object onDynamic during a group submit) are kept // fresh on subsequent field changes — even though the form itself // hasn't been submitted directly. if (!opts?.skipFormValidation) { for (const group of encompassingGroups) { if (group.state.meta.submissionAttempts === 0) continue const { fieldsErrorMap: groupFormErrors } = this.form.validateSync( cause, { group, dontUpdateFormErrorMap: true, filterFieldNames: (fieldName) => isFieldInGroup(group.name, fieldName), }, ) fieldsErrorMap = { ...fieldsErrorMap, ...(groupFormErrors[this.name] ?? {}), } } } const { hasErrored } = this.validateSync(cause, fieldsErrorMap) const groupHasErroredWeakMap = new WeakMap() for (const group of encompassingGroups) { const { hasErrored: groupHasErrored } = group.validateSync( cause, {}, { skipRelatedFieldValidation: true }, ) groupHasErroredWeakMap.set(group, groupHasErrored) } if (hasErrored && !this.options.asyncAlways) { this.getInfo().validationMetaMap[ getErrorMapKey(cause) ]?.lastAbortController.abort() const groupErrors = [] as ValidationError[][] for (const group of encompassingGroups) { group .getInfo() .validationMetaMap[getErrorMapKey(cause)]?.lastAbortController.abort() groupErrors.push(group.state.meta.errors) } return [...this.state.meta.errors, ...groupErrors.flat()] } // No error? Attempt async validation const formValidationResultPromise = opts?.skipFormValidation ? Promise.resolve({}) : this.form.validateAsync(cause) const fieldAsyncResults = this.validateAsync( cause, formValidationResultPromise, ) const groupAsyncResults: Promise[] = [] for (const group of encompassingGroups) { if (groupHasErroredWeakMap.get(group) && !group.options.asyncAlways) { continue } groupAsyncResults.push( group.validateAsync(cause, formValidationResultPromise, { skipRelatedFieldValidation: true, }), ) } if (groupAsyncResults.length === 0) { return fieldAsyncResults } return Promise.all([fieldAsyncResults, ...groupAsyncResults]).then( (results) => results.flat(), ) } /** * Handles the change event. */ handleChange = (updater: Updater) => { this.setValue(updater) } /** * Handles the blur event. */ handleBlur = () => { const prevTouched = this.state.meta.isTouched if (!prevTouched) { this.setMeta((prev) => ({ ...prev, isTouched: true })) } if (!this.state.meta.isBlurred) { this.setMeta((prev) => ({ ...prev, isBlurred: true })) } this.validate('blur') this.triggerOnBlurListener() } /** * Updates the field's errorMap */ setErrorMap = ( errorMap: ValidationErrorMap< UnwrapFieldValidateOrFn, UnwrapFieldValidateOrFn, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, UnwrapFieldAsyncValidateOrFn >, ) => { this.setMeta((prev) => ({ ...prev, errorMap: { ...prev.errorMap, ...errorMap, }, })) } /** * Parses the field's value with the given schema and returns * issues (if any). This method does NOT set any internal errors. * @param schema The standard schema to parse this field's value with. */ parseValueWithSchema = (schema: StandardSchemaV1) => { return standardSchemaValidators.validate( { value: this.state.value, validationSource: 'field' }, schema, ) } /** * Parses the field's value with the given schema and returns * issues (if any). This method does NOT set any internal errors. * @param schema The standard schema to parse this field's value with. */ parseValueWithSchemaAsync = (schema: StandardSchemaV1) => { return standardSchemaValidators.validateAsync( { value: this.state.value, validationSource: 'field' }, schema, ) } private triggerOnBlurListener = () => { const formDebounceMs = this.form.options.listeners?.onBlurDebounceMs if (formDebounceMs && formDebounceMs > 0) { if (this.timeoutIds.formListeners.blur) { clearTimeout(this.timeoutIds.formListeners.blur) } this.timeoutIds.formListeners.blur = setTimeout(() => { this.form.options.listeners?.onBlur?.({ formApi: this.form, fieldApi: this, }) }, formDebounceMs) } else { this.form.options.listeners?.onBlur?.({ formApi: this.form, fieldApi: this, }) } const fieldDebounceMs = this.options.listeners?.onBlurDebounceMs if (fieldDebounceMs && fieldDebounceMs > 0) { if (this.timeoutIds.listeners.blur) { clearTimeout(this.timeoutIds.listeners.blur) } this.timeoutIds.listeners.blur = setTimeout(() => { this.options.listeners?.onBlur?.({ value: this.state.value, fieldApi: this, }) }, fieldDebounceMs) } else { this.options.listeners?.onBlur?.({ value: this.state.value, fieldApi: this, }) } } /** * @private */ triggerOnChangeListener = () => { const formDebounceMs = this.form.options.listeners?.onChangeDebounceMs if (formDebounceMs && formDebounceMs > 0) { if (this.timeoutIds.formListeners.change) { clearTimeout(this.timeoutIds.formListeners.change) } this.timeoutIds.formListeners.change = setTimeout(() => { this.form.options.listeners?.onChange?.({ formApi: this.form, fieldApi: this, }) }, formDebounceMs) } else { this.form.options.listeners?.onChange?.({ formApi: this.form, fieldApi: this, }) } const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs if (fieldDebounceMs && fieldDebounceMs > 0) { if (this.timeoutIds.listeners.change) { clearTimeout(this.timeoutIds.listeners.change) } this.timeoutIds.listeners.change = setTimeout(() => { this.options.listeners?.onChange?.({ value: this.state.value, fieldApi: this, }) }, fieldDebounceMs) } else { this.options.listeners?.onChange?.({ value: this.state.value, fieldApi: this, }) } for (const group of this.form.formGroupApis) { if (isFieldInGroup(group.name, this.name)) { group.triggerOnChangeListener() } } } /** * @private */ triggerOnSubmitListener = () => { this.options.listeners?.onSubmit?.({ value: this.state.value, fieldApi: this, }) } } function normalizeError(rawError?: ValidationError) { if (rawError) { return rawError } return undefined } function getErrorMapKey(cause: ValidationCause) { switch (cause) { case 'submit': return 'onSubmit' case 'blur': return 'onBlur' case 'mount': return 'onMount' case 'server': return 'onServer' case 'dynamic': return 'onDynamic' case 'change': default: return 'onChange' } }