import { batch, createStore } from '@tanstack/store' import { deleteBy, determineFormLevelErrorSourceAndValue, evaluate, functionalUpdate, getAsyncValidatorArray, getBy, getSyncValidatorArray, isGlobalFormValidationError, isNonEmptyArray, mergeOpts, setBy, throttleFormState, uuid, } from './utils' import { defaultValidationLogic } from './ValidationLogic' import { isStandardSchemaValidator, standardSchemaValidators, } from './standardSchemaValidator' import { defaultFieldMeta, metaHelper } from './metaHelper' import { formEventClient } from './EventClient' import type { ReadonlyStore, Store } from '@tanstack/store' // types import type { ValidationLogicFn } from './ValidationLogic' import type { StandardSchemaV1, StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' import type { AnyFieldApi, AnyFieldMeta, AnyFieldMetaBase, FieldApi, } from './FieldApi' import type { ExtractGlobalFormError, FieldManipulator, FormValidationError, FormValidationErrorMap, GlobalFormValidationError, ListenerCause, UpdateMetaOptions, ValidationCause, ValidationError, ValidationErrorMap, ValidationErrorMapKeys, } from './types' import type { DeepKeys, DeepKeysOfType, DeepValue, RejectPromiseValidator, } from './util-types' import type { Updater } from './utils' /** * @private */ // TODO: Add the `Unwrap` type to the errors type FormErrorMapFromValidator< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, TOnBlur extends undefined | FormValidateOrFn, TOnBlurAsync extends undefined | FormAsyncValidateOrFn, TOnSubmit extends undefined | FormValidateOrFn, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnDynamic extends undefined | FormValidateOrFn, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, > = Partial< Record< DeepKeys, ValidationErrorMap< TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > > > export type FormValidateFn = (props: { value: TFormData formApi: FormApi< TFormData, // 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 > }) => unknown /** * @private */ export type FormValidateOrFn = | FormValidateFn | StandardSchemaV1 export type UnwrapFormValidateOrFn< TValidateOrFn extends undefined | FormValidateOrFn, > = [TValidateOrFn] extends [FormValidateFn] ? ExtractGlobalFormError> : [TValidateOrFn] extends [StandardSchemaV1] ? Record : undefined /** * @private */ export type FormValidateAsyncFn = (props: { value: TFormData formApi: FormApi< TFormData, // 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 > signal: AbortSignal }) => unknown | Promise export type FormValidator = { validate(options: { value: TType }, fn: TFn): ValidationError validateAsync( options: { value: TType }, fn: TFn, ): Promise> } type ValidationPromiseResult = | { fieldErrors: Partial, ValidationError>> errorMapKey: ValidationErrorMapKeys } | undefined /** * @private */ export type FormAsyncValidateOrFn = | FormValidateAsyncFn | StandardSchemaV1 export type UnwrapFormAsyncValidateOrFn< TValidateOrFn extends undefined | FormAsyncValidateOrFn, > = [TValidateOrFn] extends [FormValidateAsyncFn] ? ExtractGlobalFormError>> : [TValidateOrFn] extends [StandardSchemaV1] ? Record : undefined export interface FormValidators< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, TOnBlur extends undefined | FormValidateOrFn, TOnBlurAsync extends undefined | FormAsyncValidateOrFn, TOnSubmit extends undefined | FormValidateOrFn, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnDynamic extends undefined | FormValidateOrFn, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, > { /** * Optional function that fires as soon as the component mounts. */ onMount?: RejectPromiseValidator /** * Optional function that checks the validity of your data whenever a value changes */ onChange?: RejectPromiseValidator /** * Optional onChange asynchronous counterpart to onChange. Useful for more complex validation logic that might involve server requests. */ onChangeAsync?: TOnChangeAsync /** * The default time in milliseconds that if set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds. */ onChangeAsyncDebounceMs?: number /** * Optional function that validates the form data when a field loses focus, returns a `FormValidationError` */ onBlur?: RejectPromiseValidator /** * Optional onBlur asynchronous validation method for when a field loses focus returns a ` FormValidationError` or a promise of `Promise` */ onBlurAsync?: TOnBlurAsync /** * The default time in milliseconds that if set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds. */ onBlurAsyncDebounceMs?: number onSubmit?: RejectPromiseValidator onSubmitAsync?: TOnSubmitAsync onDynamic?: RejectPromiseValidator onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number } export interface FormListeners< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, TOnBlur extends undefined | FormValidateOrFn, TOnBlurAsync extends undefined | FormAsyncValidateOrFn, TOnSubmit extends undefined | FormValidateOrFn, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnDynamic extends undefined | FormValidateOrFn, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta = never, > { onChange?: (props: { formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > fieldApi: AnyFieldApi }) => void onChangeDebounceMs?: number onBlur?: (props: { formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > fieldApi: AnyFieldApi }) => void onBlurDebounceMs?: number onMount?: (props: { formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > }) => void onSubmit?: (props: { formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > meta: TSubmitMeta }) => void onFieldUnmount?: (props: { formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > fieldApi: AnyFieldApi }) => void } /** * An object representing the base properties of a form, unrelated to any validators */ export interface BaseFormOptions { /** * Set initial values for your form. */ defaultValues?: TFormData /** * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props */ onSubmitMeta?: TSubmitMeta } /** * An object representing the options for a form. */ export interface FormOptions< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, in out TOnBlur extends undefined | FormValidateOrFn, in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, in out TOnSubmit extends undefined | FormValidateOrFn, in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnDynamic extends undefined | FormValidateOrFn, in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, > extends BaseFormOptions { /** * The form name, used for devtools and identification */ formId?: string /** * The default state for the form. */ defaultState?: Partial< FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > > /** * If true, always run async validation, even when sync validation has produced an error. Defaults to undefined. */ asyncAlways?: boolean /** * Optional time in milliseconds if you want to introduce a delay before firing off an async action. */ asyncDebounceMs?: number /** * If true, allows the form to be submitted in an invalid state i.e. canSubmit will remain true regardless of validation errors. Defaults to undefined. */ canSubmitWhenInvalid?: boolean /** * A list of validators to pass to the form */ validators?: FormValidators< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > validationLogic?: ValidationLogicFn /** * form level listeners */ listeners?: FormListeners< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > /** * A function to be called when the form is submitted, what should happen once the user submits a valid form returns `any` or a promise `Promise` */ onSubmit?: (props: { value: TFormData formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > meta: TSubmitMeta }) => any | Promise /** * Specify an action for scenarios where the user tries to submit an invalid form. */ onSubmitInvalid?: (props: { value: TFormData formApi: FormApi< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > meta: TSubmitMeta }) => void // Only runs in `core` for the first run on the Form, any additional transforms // need to be handled at framework runtime due to complexity of comparison check // and state merging // // Made intentionally type loose to avoid headaches with framework's individual // `useTransform` hooks transform?: (data: unknown) => unknown } export type AnyFormOptions = FormOptions< any, any, any, any, any, any, any, any, any, any, any, any > /** * An object representing the validation metadata for a field. Not intended for public usage. */ export type ValidationMeta = { /** * An abort controller stored in memory to cancel previous async validation attempts. */ lastAbortController: AbortController } /** * An object representing the field information for a specific field within the form. */ export type FieldInfo = { /** * An instance of the FieldAPI. */ instance: FieldApi< TFormData, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any > | null /** * A record of field validation internal handling. */ validationMetaMap: Record } /** * An object representing the current state of the form. */ export type BaseFormState< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, in out TOnBlur extends undefined | FormValidateOrFn, in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, in out TOnSubmit extends undefined | FormValidateOrFn, in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnDynamic extends undefined | FormValidateOrFn, in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, > = { /** * The current values of the form fields. */ values: TFormData /** * The error map for the form itself. */ errorMap: ValidationErrorMap< UnwrapFormValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormAsyncValidateOrFn > /** * An internal mechanism used for keeping track of validation logic in a form. */ validationMetaMap: Record /** * A record of field metadata for each field in the form, not including the derived properties, like `errors` and such */ fieldMetaBase: Partial, AnyFieldMetaBase>> /** * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. * * Goes back to `false` when submission completes for one of the following reasons: * - the validation step returned errors. * - the `onSubmit` function has completed. * * Note: if you're running async operations in your `onSubmit` function make sure to await them to ensure `isSubmitting` is set to `false` only when the async operation completes. * * This is useful for displaying loading indicators or disabling form inputs during submission. * */ isSubmitting: boolean /** * A boolean indicating if the `onSubmit` function has completed successfully. * * Goes back to `false` at each new submission attempt. * * Note: you can use isSubmitting to check if the form is currently submitting. */ isSubmitted: boolean /** * A boolean indicating if the form or any of its fields are currently validating. */ isValidating: boolean /** * A counter for tracking the number of submission attempts. */ submissionAttempts: number /** * A boolean indicating if the last submission was successful. */ isSubmitSuccessful: boolean /** * @private, used to force a re-evaluation of the form state when options change */ _force_re_eval?: boolean } export type AnyBaseFormState = BaseFormState< any, any, any, any, any, any, any, any, any, any, any > export type DerivedFormState< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, in out TOnBlur extends undefined | FormValidateOrFn, in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, in out TOnSubmit extends undefined | FormValidateOrFn, in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnDynamic extends undefined | FormValidateOrFn, in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, > = { /** * A boolean indicating if the form is currently validating. */ isFormValidating: boolean /** * A boolean indicating if the form is valid. */ isFormValid: boolean /** * The error array for the form itself. */ errors: Array< NonNullable< | UnwrapFormValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormAsyncValidateOrFn > > /** * A boolean indicating if any of the form fields are currently validating. */ isFieldsValidating: boolean /** * A boolean indicating if all the form fields are valid. Evaluates `true` if there are no field errors. */ isFieldsValid: boolean /** * A boolean indicating if any of the form fields have been touched. */ isTouched: boolean /** * A boolean indicating if any of the form fields have been blurred. */ isBlurred: boolean /** * A boolean indicating if any of the form's fields' values have been modified by the user. Evaluates `true` if the user have modified at least one of the fields. Opposite of `isPristine`. */ isDirty: boolean /** * A boolean indicating if none of the form's fields' values have been modified by the user. Evaluates `true` if the user have not modified any of the fields. Opposite of `isDirty`. */ isPristine: boolean /** * A boolean indicating if all of the form's fields are the same as default values. */ isDefaultValue: boolean /** * A boolean indicating if the form and all its fields are valid. Evaluates `true` if there are no errors. */ isValid: boolean /** * A boolean indicating if the form can be submitted based on its current state. */ canSubmit: boolean /** * A record of field metadata for each field in the form. */ fieldMeta: Partial, AnyFieldMeta>> } export interface FormState< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, in out TOnBlur extends undefined | FormValidateOrFn, in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, in out TOnSubmit extends undefined | FormValidateOrFn, in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnDynamic extends undefined | FormValidateOrFn, in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, > extends BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer >, DerivedFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > {} export type AnyFormState = FormState< any, any, any, any, any, any, any, any, any, any, any > function getDefaultFormState< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, TOnChangeAsync extends undefined | FormAsyncValidateOrFn, TOnBlur extends undefined | FormValidateOrFn, TOnBlurAsync extends undefined | FormAsyncValidateOrFn, TOnSubmit extends undefined | FormValidateOrFn, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnDynamic extends undefined | FormValidateOrFn, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, TOnServer extends undefined | FormAsyncValidateOrFn, >( defaultState: Partial< FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > >, ): BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > { return { values: defaultState.values ?? ({} as never), errorMap: defaultState.errorMap ?? {}, fieldMetaBase: defaultState.fieldMetaBase ?? ({} as never), isSubmitted: defaultState.isSubmitted ?? false, isSubmitting: defaultState.isSubmitting ?? false, isValidating: defaultState.isValidating ?? false, submissionAttempts: defaultState.submissionAttempts ?? 0, isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, validationMetaMap: defaultState.validationMetaMap ?? { onChange: undefined, onBlur: undefined, onSubmit: undefined, onMount: undefined, onServer: undefined, onDynamic: undefined, }, } } /** * @public * * A type representing the Form API with all generics set to `any` for convenience. */ export type AnyFormApi = FormApi< 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 Form API. It handles the logic and interactions with the form state. * * Normally, you will not need to create a new `FormApi` instance directly. Instead, you will use a framework * hook/function like `useForm` or `createForm` 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 FormApi` constructor. */ export class FormApi< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, in out TOnChangeAsync extends undefined | FormAsyncValidateOrFn, in out TOnBlur extends undefined | FormValidateOrFn, in out TOnBlurAsync extends undefined | FormAsyncValidateOrFn, in out TOnSubmit extends undefined | FormValidateOrFn, in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, in out TOnDynamic extends undefined | FormValidateOrFn, in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, > implements FieldManipulator { /** * The options for the form. */ options: FormOptions< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta > = {} baseStore!: Store< BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > > fieldMetaDerived: Store< FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer >['fieldMeta'] > store: ReadonlyStore< FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > > /** * A record of field information for each field in the form. */ fieldInfo: Partial, FieldInfo>> = {} get state() { return this.store.state } /** * @private */ timeoutIds: { validations: Record | null> listeners: Record | null> formListeners: Record | null> } /** * @private */ _formId: string /** * @private */ private _devtoolsSubmissionOverride: boolean /** * Constructs a new `FormApi` instance with the given form options. */ constructor( opts?: FormOptions< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta >, ) { this.timeoutIds = { validations: {} as Record, listeners: {} as Record, formListeners: {} as Record, } this._formId = opts?.formId ?? uuid() this._devtoolsSubmissionOverride = false let baseStoreVal: BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > = getDefaultFormState({ ...(opts?.defaultState as any), values: opts?.defaultValues ?? opts?.defaultState?.values, isFormValid: true, }) if (opts?.transform) { baseStoreVal = ( opts.transform({ state: baseStoreVal }) as { state: unknown } ).state as never for (const errKey of Object.keys(baseStoreVal.errorMap)) { const errKeyMap = baseStoreVal.errorMap[errKey as never] as | GlobalFormValidationError | undefined if ( errKeyMap === undefined || !isGlobalFormValidationError(errKeyMap) ) { continue } for (const fieldName of Object.keys(errKeyMap.fields)) { const fieldErr = errKeyMap.fields[fieldName] if (fieldErr === undefined) { continue } const existingFieldMeta = baseStoreVal.fieldMetaBase[ fieldName as never ] as AnyFieldMetaBase | undefined baseStoreVal.fieldMetaBase[fieldName as never] = { isTouched: false, isValidating: false, isBlurred: false, isDirty: false, _arrayVersion: 0, ...(existingFieldMeta ?? {}), errorSourceMap: { ...(existingFieldMeta?.['errorSourceMap'] ?? {}), onChange: 'form', }, errorMap: { ...(existingFieldMeta?.['errorMap'] ?? {}), [errKey as never]: fieldErr, }, } satisfies AnyFieldMetaBase as never } } } this.baseStore = createStore(baseStoreVal) as never let prevBaseStore: | BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > | undefined = undefined this.fieldMetaDerived = createStore( (prevVal: Record, AnyFieldMeta> | undefined) => { const currBaseStore = this.baseStore.get() let originalMetaCount = 0 const fieldMeta: FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer >['fieldMeta'] = {} for (const fieldName of Object.keys( currBaseStore.fieldMetaBase, ) as Array) { const currBaseMeta = currBaseStore.fieldMetaBase[ fieldName as never ] as AnyFieldMetaBase const prevBaseMeta = prevBaseStore?.fieldMetaBase[ fieldName as never ] as AnyFieldMetaBase | undefined const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] const curFieldVal = getBy(currBaseStore.values, fieldName) let fieldErrors = prevFieldInfo?.errors if ( !prevBaseMeta || currBaseMeta.errorMap !== prevBaseMeta.errorMap ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition fieldErrors = Object.values(currBaseMeta.errorMap ?? {}).filter( (val) => val !== undefined, ) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const fieldInstance = this.getFieldInfo(fieldName)?.instance if (!fieldInstance || !fieldInstance.options.disableErrorFlat) { fieldErrors = fieldErrors.flat(1) } } // As primitives, we don't need to aggressively persist the same referential value for performance reasons const isFieldValid = !isNonEmptyArray(fieldErrors) const isFieldPristine = !currBaseMeta.isDirty const isDefaultValue = evaluate( curFieldVal, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this.getFieldInfo(fieldName)?.instance?.options.defaultValue ?? getBy(this.options.defaultValues, fieldName), ) if ( prevFieldInfo && prevFieldInfo.isPristine === isFieldPristine && prevFieldInfo.isValid === isFieldValid && prevFieldInfo.isDefaultValue === isDefaultValue && prevFieldInfo.errors === fieldErrors && currBaseMeta === prevBaseMeta ) { fieldMeta[fieldName] = prevFieldInfo originalMetaCount++ continue } fieldMeta[fieldName] = { ...currBaseMeta, errors: fieldErrors ?? [], isPristine: isFieldPristine, isValid: isFieldValid, isDefaultValue: isDefaultValue, } satisfies AnyFieldMeta as AnyFieldMeta } if (!Object.keys(currBaseStore.fieldMetaBase).length) return fieldMeta if ( prevVal && originalMetaCount === Object.keys(currBaseStore.fieldMetaBase).length ) { return prevVal } prevBaseStore = this.baseStore.get() return fieldMeta }, ) as never let prevBaseStoreForStore: | BaseFormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > | undefined = undefined this.store = createStore< FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > >((prevVal) => { const currBaseStore = this.baseStore.get() const currFieldMeta = this.fieldMetaDerived.get() // Computed state const fieldMetaValues = Object.values(currFieldMeta).filter( Boolean, ) as AnyFieldMeta[] const isFieldsValidating = fieldMetaValues.some( (field) => field.isValidating, ) const isFieldsValid = fieldMetaValues.every((field) => field.isValid) const isTouched = fieldMetaValues.some((field) => field.isTouched) const isBlurred = fieldMetaValues.some((field) => field.isBlurred) const isDefaultValue = fieldMetaValues.every( (field) => field.isDefaultValue, ) const shouldInvalidateOnMount = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition isTouched && currBaseStore.errorMap?.onMount const isDirty = fieldMetaValues.some((field) => field.isDirty) const isPristine = !isDirty const hasOnMountError = Boolean( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition currBaseStore.errorMap?.onMount || // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition fieldMetaValues.some((f) => f?.errorMap?.onMount), ) const isValidating = !!isFieldsValidating // As `errors` is not a primitive, we need to aggressively persist the same referencial value for performance reasons let errors = prevVal?.errors ?? [] if ( !prevBaseStoreForStore || currBaseStore.errorMap !== prevBaseStoreForStore.errorMap ) { errors = Object.values(currBaseStore.errorMap).reduce< Array< NonNullable< | UnwrapFormValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormAsyncValidateOrFn > > >((prev, curr) => { if (curr === undefined) return prev if (curr && isGlobalFormValidationError(curr)) { prev.push(curr.form as never) return prev } prev.push(curr as never) return prev }, []) } const isFormValid = errors.length === 0 const isValid = isFieldsValid && isFormValid const submitInvalid = this.options.canSubmitWhenInvalid ?? false const canSubmit = (currBaseStore.submissionAttempts === 0 && !isTouched && !hasOnMountError) || (!isValidating && !currBaseStore.isSubmitting && isValid) || submitInvalid let errorMap = currBaseStore.errorMap if (shouldInvalidateOnMount) { errors = errors.filter((err) => err !== currBaseStore.errorMap.onMount) errorMap = Object.assign(errorMap, { onMount: undefined }) } if ( prevVal && prevBaseStoreForStore && prevVal.errorMap === errorMap && prevVal.fieldMeta === this.fieldMetaDerived.state && prevVal.errors === errors && prevVal.isFieldsValidating === isFieldsValidating && prevVal.isFieldsValid === isFieldsValid && prevVal.isFormValid === isFormValid && prevVal.isValid === isValid && prevVal.canSubmit === canSubmit && prevVal.isTouched === isTouched && prevVal.isBlurred === isBlurred && prevVal.isPristine === isPristine && prevVal.isDefaultValue === isDefaultValue && prevVal.isDirty === isDirty && evaluate(prevBaseStoreForStore, currBaseStore) ) { return prevVal } const state = { ...currBaseStore, errorMap, fieldMeta: this.fieldMetaDerived.state, errors, isFieldsValidating, isFieldsValid, isFormValid, isValid, canSubmit, isTouched, isBlurred, isPristine, isDefaultValue, isDirty, } as FormState< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer > prevBaseStoreForStore = this.baseStore.get() return state }) this.handleSubmit = this.handleSubmit.bind(this) this.update(opts || {}) } get formId(): string { return this._formId } /** * @private */ runValidator< TValue extends TStandardSchemaValidatorValue & { formApi: AnyFormApi }, TType extends 'validate' | 'validateAsync', >(props: { validate: TType extends 'validate' ? FormValidateOrFn : FormAsyncValidateOrFn value: TValue type: TType }): unknown { if (isStandardSchemaValidator(props.validate)) { return standardSchemaValidators[props.type]( props.value, props.validate, ) as never } return (props.validate as FormValidateFn)(props.value) as never } mount = () => { // devtool broadcasts const cleanupDevtoolBroadcast = this.store.subscribe(() => { throttleFormState(this) }) // devtool requests const cleanupFormStateListener = formEventClient.on( 'request-form-state', (e) => { if (e.payload.id === this._formId) { formEventClient.emit('form-api', { id: this._formId, state: this.store.state, options: this.options, }) } }, ) const cleanupFormResetListener = formEventClient.on( 'request-form-reset', (e) => { if (e.payload.id === this._formId) { this.reset() } }, ) const cleanupFormForceSubmitListener = formEventClient.on( 'request-form-force-submit', (e) => { if (e.payload.id === this._formId) { this._devtoolsSubmissionOverride = true this.handleSubmit() this._devtoolsSubmissionOverride = false } }, ) const cleanup = () => { cleanupFormForceSubmitListener() cleanupFormResetListener() cleanupFormStateListener() cleanupDevtoolBroadcast.unsubscribe() // broadcast form unmount for devtools formEventClient.emit('form-unmounted', { id: this._formId, }) } this.options.listeners?.onMount?.({ formApi: this }) const { onMount } = this.options.validators || {} // broadcast form state for devtools on mounting formEventClient.emit('form-api', { id: this._formId, state: this.store.state, options: this.options, }) // if no validation skip if (!onMount) return cleanup // validate this.validateSync('mount') return cleanup } /** * Updates the form options and form state. */ update = ( options?: FormOptions< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta >, ) => { if (!options) return const oldOptions = this.options // Options need to be updated first so that when the store is updated, the state is correct for the derived state this.options = options const shouldUpdateValues = options.defaultValues && !evaluate(options.defaultValues, oldOptions.defaultValues) && !this.state.isTouched const shouldUpdateState = !evaluate(options.defaultState, oldOptions.defaultState) && !this.state.isTouched if (!shouldUpdateValues && !shouldUpdateState) return batch(() => { this.baseStore.setState(() => getDefaultFormState( Object.assign( {}, this.state as any, shouldUpdateState ? options.defaultState : {}, shouldUpdateValues ? { values: options.defaultValues, } : {}, ), ), ) }) formEventClient.emit('form-api', { id: this._formId, state: this.store.state, options: this.options, }) } /** * Resets the form state to the default values. * If values are provided, the form will be reset to those values instead and the default values will be updated. * * @param values - Optional values to reset the form to. * @param opts - Optional options to control the reset behavior. */ reset = (values?: TFormData, opts?: { keepDefaultValues?: boolean }) => { const { fieldMeta: currentFieldMeta } = this.state const fieldMetaBase = this.resetFieldMeta(currentFieldMeta) if (values && !opts?.keepDefaultValues) { this.options = { ...this.options, defaultValues: values, } } this.baseStore.setState(() => { let nextValues = values ?? this.options.defaultValues ?? this.options.defaultState?.values if (!values) { ;(Object.values(this.fieldInfo) as FieldInfo[]).forEach( (fieldInfo) => { if ( fieldInfo.instance && fieldInfo.instance.options.defaultValue !== undefined ) { nextValues = setBy( nextValues, fieldInfo.instance.name, fieldInfo.instance.options.defaultValue, ) } }, ) } return getDefaultFormState({ ...(this.options.defaultState as any), values: nextValues, fieldMetaBase, }) }) } /** * Validates all fields according to the FIELD level validators. * This will ignore FORM level validators, use form.validate({ValidationCause}) for a complete validation */ validateAllFields = async (cause: ValidationCause) => { const fieldValidationPromises: Promise[] = [] as any batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { if (!field.instance) return const fieldInstance = field.instance // Validate the field fieldValidationPromises.push( // Remember, `validate` is either a sync operation or a promise Promise.resolve().then(() => fieldInstance.validate(cause, { skipFormValidation: true }), ), ) // If any fields are not touched if (!field.instance.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } }, ) }) const fieldErrorMapMap = await Promise.all(fieldValidationPromises) return fieldErrorMapMap.flat() } /** * Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type. */ validateArrayFieldsStartingFrom = async < TField extends DeepKeysOfType, >( field: TField, index: number, cause: ValidationCause, ) => { const currentValue = this.getFieldValue(field) const lastIndex = Array.isArray(currentValue) ? Math.max((currentValue as Array).length - 1, 0) : null // We have to validate all fields that have shifted (at least the current field) const fieldKeysToValidate = [`${field}[${index}]`] for (let i = index + 1; i <= (lastIndex ?? 0); i++) { fieldKeysToValidate.push(`${field}[${i}]`) } // We also have to include all fields that are nested in the shifted fields const fieldsToValidate = Object.keys(this.fieldInfo).filter((fieldKey) => fieldKeysToValidate.some((key) => fieldKey.startsWith(key)), ) as DeepKeys[] // Validate the fields const fieldValidationPromises: Promise[] = [] as any batch(() => { fieldsToValidate.forEach((nestedField) => { fieldValidationPromises.push( Promise.resolve().then(() => this.validateField(nestedField, cause)), ) }) }) const fieldErrorMapMap = await Promise.all(fieldValidationPromises) return fieldErrorMapMap.flat() } /** * Validates a specified field in the form using the correct handlers for a given validation type. */ validateField = >( field: TField, cause: ValidationCause, ) => { const fieldInstance = this.fieldInfo[field]?.instance if (!fieldInstance) { const { hasErrored } = this.validateSync(cause) if (hasErrored && !this.options.asyncAlways) { return this.getFieldMeta(field)?.errors ?? [] } return this.validateAsync(cause).then(() => { return this.getFieldMeta(field)?.errors ?? [] }) } // If the field is not touched (same logic as in validateAllFields) if (!fieldInstance.state.meta.isTouched) { // Mark it as touched fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) } return fieldInstance.validate(cause) } /** * TODO: This code is copied from FieldApi, we should refactor to share * @private */ validateSync = ( cause: ValidationCause, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > } => { const validates = getSyncValidatorArray(cause, { ...this.options, form: this, validationLogic: this.options.validationLogic || defaultValidationLogic, }) let hasErrored = false as boolean // This map will only include fields that have errors in the current validation cycle const currentValidationErrorMap: FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > = {} batch(() => { for (const validateObj of validates) { if (!validateObj.validate) continue const rawError = this.runValidator({ validate: validateObj.validate, value: { value: this.state.values, formApi: this, validationSource: 'form', }, type: 'validate', }) const { formError, fieldErrors } = normalizeError(rawError) const errorMapKey = getErrorMapKey(validateObj.cause) const allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && !fieldErrors?.[field] ) { continue } const fieldMeta = this.getFieldMeta(field) ?? defaultFieldMeta const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, } = fieldMeta const newFormValidatorError = fieldErrors?.[field] const { newErrorValue, newSource } = determineFormLevelErrorSourceAndValue({ newFormValidatorError, isPreviousErrorFromFormValidator: // These conditional checks are required, otherwise we get runtime errors. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition currentErrorMapSource?.[errorMapKey] === 'form', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition previousErrorValue: currentErrorMap?.[errorMapKey], }) if (newSource === 'form') { currentValidationErrorMap[field] = { ...currentValidationErrorMap[field], [errorMapKey]: newFormValidatorError, } } // This conditional check is required, otherwise we get runtime errors. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (currentErrorMap?.[errorMapKey] !== newErrorValue) { this.setFieldMeta(field, (prev = defaultFieldMeta) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: newErrorValue, }, errorSourceMap: { ...prev.errorSourceMap, [errorMapKey]: newSource, }, })) } } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.state.errorMap?.[errorMapKey] !== formError) { this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: formError, }, })) } if (formError || fieldErrors) { hasErrored = true } } /** * 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.errorMap?.[submitErrKey] && cause !== 'submit' && !hasErrored ) { this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [submitErrKey]: undefined, }, })) } /** * when we have an error for onServer in the state, we want * to clear the error as soon as the user enters a valid value in the field */ const serverErrKey = getErrorMapKey('server') if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this.state.errorMap?.[serverErrKey] && cause !== 'server' && !hasErrored ) { this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [serverErrKey]: undefined, }, })) } }) return { hasErrored, fieldsErrorMap: currentValidationErrorMap } } /** * @private */ validateAsync = async ( cause: ValidationCause, ): Promise< FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > > => { const validates = getAsyncValidatorArray(cause, { ...this.options, form: this, validationLogic: this.options.validationLogic || defaultValidationLogic, }) if (!this.state.isFormValidating) { this.baseStore.setState((prev) => ({ ...prev, isFormValidating: true })) } /** * 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 promises: Promise>[] = [] let fieldErrorsFromFormValidators: | Partial, ValidationError>> | undefined for (const validateObj of validates) { if (!validateObj.validate) continue const key = getErrorMapKey(validateObj.cause) const fieldValidatorMeta = this.state.validationMetaMap[key] fieldValidatorMeta?.lastAbortController.abort() const controller = new AbortController() this.state.validationMetaMap[key] = { lastAbortController: controller, } promises.push( new Promise>(async (resolve) => { let rawError!: | ValidationError | FormValidationError | undefined try { rawError = await new Promise((rawResolve, rawReject) => { setTimeout(async () => { if (controller.signal.aborted) return rawResolve(undefined) try { rawResolve( await this.runValidator({ validate: validateObj.validate!, value: { value: this.state.values, formApi: this, validationSource: 'form', signal: controller.signal, }, type: 'validateAsync', }), ) } catch (e) { rawReject(e) } }, validateObj.debounceMs) }) } catch (e: unknown) { rawError = e as ValidationError } const { formError, fieldErrors: fieldErrorsFromNormalizeError } = normalizeError(rawError) if (fieldErrorsFromNormalizeError) { fieldErrorsFromFormValidators = fieldErrorsFromFormValidators ? { ...fieldErrorsFromFormValidators, ...fieldErrorsFromNormalizeError, } : fieldErrorsFromNormalizeError } const errorMapKey = getErrorMapKey(validateObj.cause) for (const field of Object.keys( this.state.fieldMeta, ) as DeepKeys[]) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } const fieldMeta = this.getFieldMeta(field) if (!fieldMeta) continue const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, } = fieldMeta const newFormValidatorError = fieldErrorsFromFormValidators?.[field] const { newErrorValue, newSource } = determineFormLevelErrorSourceAndValue({ newFormValidatorError, isPreviousErrorFromFormValidator: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition currentErrorMapSource?.[errorMapKey] === 'form', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition previousErrorValue: currentErrorMap?.[errorMapKey], }) if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition currentErrorMap?.[errorMapKey] !== newErrorValue ) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: newErrorValue, }, errorSourceMap: { ...prev.errorSourceMap, [errorMapKey]: newSource, }, })) } } this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: formError, }, })) resolve( fieldErrorsFromFormValidators ? { fieldErrors: fieldErrorsFromFormValidators, errorMapKey } : undefined, ) }), ) } let results: ValidationPromiseResult[] = [] const fieldsErrorMap: FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > = {} if (promises.length) { results = await Promise.all(promises) for (const fieldValidationResult of results) { if (fieldValidationResult?.fieldErrors) { const { errorMapKey } = fieldValidationResult for (const [field, fieldError] of Object.entries( fieldValidationResult.fieldErrors, )) { const oldErrorMap = fieldsErrorMap[field as DeepKeys] || {} const newErrorMap = { ...oldErrorMap, [errorMapKey]: fieldError, } fieldsErrorMap[field as DeepKeys] = newErrorMap } } } } this.baseStore.setState((prev) => ({ ...prev, isFormValidating: false, })) return fieldsErrorMap } /** * @private */ validate = ( cause: ValidationCause, ): | FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > | Promise< FormErrorMapFromValidator< TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync > > => { // Attempt to sync validate first const { hasErrored, fieldsErrorMap } = this.validateSync(cause) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation return this.validateAsync(cause) } // Needs to edgecase in the React adapter specifically to avoid type errors handleSubmit(): Promise handleSubmit(submitMeta: TSubmitMeta): Promise handleSubmit(submitMeta?: TSubmitMeta): Promise { return this._handleSubmit(submitMeta) } /** * Handles the form submission, performs validation, and calls the appropriate onSubmit or onSubmitInvalid callbacks. */ _handleSubmit = async (submitMeta?: TSubmitMeta): Promise => { this.baseStore.setState((old) => ({ ...old, // Submission attempts mark the form as not submitted isSubmitted: false, // Count submission attempts submissionAttempts: old.submissionAttempts + 1, isSubmitSuccessful: false, // Reset isSubmitSuccessful at the start of submission })) batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { if (!field.instance) return // If any fields are not touched if (!field.instance.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } }, ) }) const submitMetaArg = submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) if (!this.state.canSubmit && !this._devtoolsSubmissionOverride) { this.options.onSubmitInvalid?.({ value: this.state.values, formApi: this, meta: submitMetaArg, }) return } this.baseStore.setState((d) => ({ ...d, isSubmitting: true })) const done = () => { this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false })) } await this.validateAllFields('submit') if (!this.state.isFieldsValid) { done() this.options.onSubmitInvalid?.({ value: this.state.values, formApi: this, meta: submitMetaArg, }) formEventClient.emit('form-submission', { id: this._formId, submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'validateAllFields', errors: (Object.values(this.state.fieldMeta) as AnyFieldMeta[]) .map((meta: AnyFieldMeta) => meta.errors) .flat(), }) return } await this.validate('submit') // Fields are invalid, do not submit if (!this.state.isValid) { done() this.options.onSubmitInvalid?.({ value: this.state.values, formApi: this, meta: submitMetaArg, }) formEventClient.emit('form-submission', { id: this._formId, submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'validate', errors: this.state.errors, }) return } batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { field.instance?.options.listeners?.onSubmit?.({ value: field.instance.state.value, fieldApi: field.instance, }) }, ) }) this.options.listeners?.onSubmit?.({ formApi: this, meta: submitMetaArg }) try { // Run the submit code await this.options.onSubmit?.({ value: this.state.values, formApi: this, meta: submitMetaArg, }) batch(() => { this.baseStore.setState((prev) => ({ ...prev, isSubmitted: true, isSubmitSuccessful: true, // Set isSubmitSuccessful to true on successful submission })) formEventClient.emit('form-submission', { id: this._formId, submissionAttempt: this.state.submissionAttempts, successful: true, }) done() }) } catch (err) { this.baseStore.setState((prev) => ({ ...prev, isSubmitSuccessful: false, // Ensure isSubmitSuccessful is false if an error occurs })) formEventClient.emit('form-submission', { id: this._formId, submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'inflight', onError: err, }) done() throw err } } /** * Gets the value of the specified field. */ getFieldValue = >( field: TField, ): DeepValue => getBy(this.state.values, field) /** * Gets the metadata of the specified field. */ getFieldMeta = >( field: TField, ): AnyFieldMeta | undefined => { return this.state.fieldMeta[field] } /** * Gets the field info of the specified field. */ getFieldInfo = >( field: TField, ): FieldInfo => { return (this.fieldInfo[field] ||= { instance: null, validationMetaMap: { onChange: undefined, onBlur: undefined, onSubmit: undefined, onMount: undefined, onServer: undefined, onDynamic: undefined, }, }) } /** * Updates the metadata of the specified field. */ setFieldMeta = >( field: TField, updater: Updater, ) => { this.baseStore.setState((prev) => { return { ...prev, fieldMetaBase: { ...prev.fieldMetaBase, [field]: functionalUpdate( updater, prev.fieldMetaBase[field] as never, ), }, } }) } /** * resets every field's meta */ resetFieldMeta = >( fieldMeta: Partial>, ): Partial> => { return Object.keys(fieldMeta).reduce( (acc, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, {} as Partial>, ) } /** * Sets the value of the specified field and optionally updates the touched state. */ setFieldValue = >( field: TField, updater: Updater>, opts?: UpdateMetaOptions, ) => { const dontUpdateMeta = opts?.dontUpdateMeta ?? false const dontRunListeners = opts?.dontRunListeners ?? false const dontValidate = opts?.dontValidate ?? false batch(() => { if (!dontUpdateMeta) { this.setFieldMeta(field, (prev) => ({ ...prev, isTouched: true, isDirty: true, errorMap: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ...prev?.errorMap, onMount: undefined, }, })) } this.baseStore.setState((prev) => { return { ...prev, values: setBy(prev.values, field, updater), } }) }) if (!dontRunListeners) { this.getFieldInfo(field).instance?.triggerOnChangeListener() } if (!dontValidate) { this.validateField(field, 'change') } } deleteField = >(field: TField) => { const subFieldsToDelete = Object.keys(this.fieldInfo).filter((f) => { const fieldStr = field.toString() return f !== fieldStr && f.startsWith(fieldStr) }) const fieldsToDelete = [...subFieldsToDelete, field] // Cleanup the last fields this.baseStore.setState((prev) => { const newState = { ...prev } fieldsToDelete.forEach((f) => { newState.values = deleteBy(newState.values, f) delete this.fieldInfo[f as never] delete newState.fieldMetaBase[f as never] }) return newState }) } /** * Pushes a value into an array field. */ pushFieldValue = >( field: TField, value: DeepValue extends any[] ? DeepValue[number] : never, options?: UpdateMetaOptions, ) => { this.setFieldValue( field, (prev) => [...(Array.isArray(prev) ? prev : []), value] as any, options, ) metaHelper(this).bumpArrayVersion(field) } insertFieldValue = async >( field: TField, index: number, value: DeepValue extends any[] ? DeepValue[number] : never, options?: UpdateMetaOptions, ) => { this.setFieldValue( field, (prev) => { return [ ...(prev as DeepValue[]).slice(0, index), value, ...(prev as DeepValue[]).slice(index), ] as any }, mergeOpts(options, { dontValidate: true }), ) const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // Validate the whole array + all fields that have shifted await this.validateField(field, 'change') } // Shift down all meta after validating to make sure the new field has been mounted metaHelper(this).handleArrayInsert(field, index) if (!dontValidate) { await this.validateArrayFieldsStartingFrom(field, index, 'change') } } /** * Replaces a value into an array field at the specified index. */ replaceFieldValue = async >( field: TField, index: number, value: DeepValue extends any[] ? DeepValue[number] : never, options?: UpdateMetaOptions, ) => { this.setFieldValue( field, (prev) => { return (prev as DeepValue[]).map((d, i) => i === index ? value : d, ) as any }, mergeOpts(options, { dontValidate: true }), ) metaHelper(this).bumpArrayVersion(field) const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // Validate the whole array + all fields that have shifted await this.validateField(field, 'change') await this.validateArrayFieldsStartingFrom(field, index, 'change') } } /** * Removes a value from an array field at the specified index. */ removeFieldValue = async >( field: TField, index: number, options?: UpdateMetaOptions, ) => { const fieldValue = this.getFieldValue(field) const lastIndex = Array.isArray(fieldValue) ? Math.max((fieldValue as Array).length - 1, 0) : null this.setFieldValue( field, (prev) => { return (prev as DeepValue[]).filter( (_d, i) => i !== index, ) as any }, mergeOpts(options, { dontValidate: true }), ) // Shift up all meta metaHelper(this).handleArrayRemove(field, index) if (lastIndex !== null) { const start = `${field}[${lastIndex}]` this.deleteField(start as never) } const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // Validate the whole array + all fields that have shifted await this.validateField(field, 'change') await this.validateArrayFieldsStartingFrom(field, index, 'change') } } /** * Swaps the values at the specified indices within an array field. */ swapFieldValues = >( field: TField, index1: number, index2: number, options?: UpdateMetaOptions, ) => { this.setFieldValue( field, (prev: any) => { const prev1 = prev[index1]! const prev2 = prev[index2]! return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1) }, mergeOpts(options, { dontValidate: true }), ) // Swap meta metaHelper(this).handleArraySwap(field, index1, index2) const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // Validate the whole array this.validateField(field, 'change') // Validate the swapped fields this.validateField(`${field}[${index1}]` as DeepKeys, 'change') this.validateField(`${field}[${index2}]` as DeepKeys, 'change') } } /** * Moves the value at the first specified index to the second specified index within an array field. */ moveFieldValues = >( field: TField, index1: number, index2: number, options?: UpdateMetaOptions, ) => { this.setFieldValue( field, (prev: any) => { const next: any = [...prev] next.splice(index2, 0, next.splice(index1, 1)[0]) return next }, mergeOpts(options, { dontValidate: true }), ) // Move meta between index1 and index2 metaHelper(this).handleArrayMove(field, index1, index2) const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // Validate the whole array this.validateField(field, 'change') // Validate the moved fields this.validateField(`${field}[${index1}]` as DeepKeys, 'change') this.validateField(`${field}[${index2}]` as DeepKeys, 'change') } } /** * Clear all values within an array field. */ clearFieldValues = >( field: TField, options?: UpdateMetaOptions, ) => { const fieldValue = this.getFieldValue(field) const lastIndex = Array.isArray(fieldValue) ? Math.max((fieldValue as unknown[]).length - 1, 0) : null this.setFieldValue( field, [] as any, mergeOpts(options, { dontValidate: true }), ) metaHelper(this).bumpArrayVersion(field) if (lastIndex !== null) { for (let i = 0; i <= lastIndex; i++) { const fieldKey = `${field}[${i}]` this.deleteField(fieldKey as never) } } const dontValidate = options?.dontValidate ?? false if (!dontValidate) { // validate array change this.validateField(field, 'change') } } /** * Resets the field value and meta to default state */ resetField = >(field: TField) => { this.baseStore.setState((prev) => { const fieldDefault = this.getFieldInfo(field).instance?.options.defaultValue const formDefault = getBy(this.options.defaultValues, field) const targetValue = fieldDefault ?? formDefault return { ...prev, fieldMetaBase: { ...prev.fieldMetaBase, [field]: defaultFieldMeta, }, values: targetValue !== undefined ? setBy(prev.values, field, targetValue) : prev.values, } }) } /** * Updates the form's errorMap */ setErrorMap = ( errorMap: FormValidationErrorMap< TFormData, UnwrapFormValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormAsyncValidateOrFn >, ) => { batch(() => { Object.entries(errorMap).forEach(([key, value]) => { const errorMapKey = key as ValidationErrorMapKeys if (isGlobalFormValidationError(value)) { const { formError, fieldErrors } = normalizeError(value) for (const fieldName of Object.keys( this.fieldInfo, ) as DeepKeys[]) { const fieldMeta = this.getFieldMeta(fieldName) if (!fieldMeta) continue this.setFieldMeta(fieldName, (prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: fieldErrors?.[fieldName], }, errorSourceMap: { ...prev.errorSourceMap, [errorMapKey]: 'form', }, })) } this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: formError, }, })) } else { this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, [errorMapKey]: value, }, })) } }) }) } /** * Returns form and field level errors */ getAllErrors = (): { form: { errors: Array< NonNullable< | UnwrapFormValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormValidateOrFn | UnwrapFormAsyncValidateOrFn | UnwrapFormAsyncValidateOrFn > > errorMap: ValidationErrorMap< UnwrapFormValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormValidateOrFn, UnwrapFormAsyncValidateOrFn, UnwrapFormAsyncValidateOrFn > } fields: Record< DeepKeys, { errors: ValidationError[]; errorMap: ValidationErrorMap } > } => { return { form: { errors: this.state.errors, errorMap: this.state.errorMap, }, fields: Object.entries(this.state.fieldMeta).reduce( (acc, [fieldName, fieldMeta]) => { if ( Object.keys(fieldMeta as AnyFieldMeta).length && (fieldMeta as AnyFieldMeta).errors.length ) { acc[fieldName as DeepKeys] = { errors: (fieldMeta as AnyFieldMeta).errors, errorMap: (fieldMeta as AnyFieldMeta).errorMap, } } return acc }, {} as Record< DeepKeys, { errors: ValidationError[]; errorMap: ValidationErrorMap } >, ), } } /** * Parses the form's values with a given standard schema and returns * issues (if any). This method does NOT set any internal errors. * @param schema The standard schema to parse the form values with. */ parseValuesWithSchema = (schema: StandardSchemaV1) => { return standardSchemaValidators.validate( { value: this.state.values, validationSource: 'form' }, schema, ) } /** * Parses the form's values with a given standard schema and returns * issues (if any). This method does NOT set any internal errors. * @param schema The standard schema to parse the form values with. */ parseValuesWithSchemaAsync = ( schema: StandardSchemaV1, ) => { return standardSchemaValidators.validateAsync( { value: this.state.values, validationSource: 'form' }, schema, ) } } function normalizeError(rawError?: FormValidationError): { formError: ValidationError fieldErrors?: Partial, ValidationError>> } { if (rawError) { if (isGlobalFormValidationError(rawError)) { const formError = normalizeError(rawError.form).formError const fieldErrors = rawError.fields return { formError, fieldErrors } as never } return { formError: rawError } } return { formError: 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' } }