From 9edad016b29abe6de22acf77456c922b67f73325 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 01:50:35 -0700 Subject: [PATCH 01/19] feat: initial work on FormGroupApi in core --- packages/form-core/src/FieldApi.ts | 1 + packages/form-core/src/FormApi.ts | 75 +++-- packages/form-core/src/FormGroupApi.ts | 311 ++++++++++++++++++ packages/form-core/src/index.ts | 1 + packages/form-core/tests/FormGroupApi.spec.ts | 146 ++++++++ 5 files changed, 512 insertions(+), 22 deletions(-) create mode 100644 packages/form-core/src/FormGroupApi.ts create mode 100644 packages/form-core/tests/FormGroupApi.spec.ts diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a064a83de..70ba9d9ab 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -384,6 +384,7 @@ export interface FieldListeners< onMount?: FieldListenerFn onUnmount?: FieldListenerFn onSubmit?: FieldListenerFn + onGroupSubmit?: FieldListenerFn } /** diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2317fb2b9..b99f0fdb5 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -851,6 +851,13 @@ export type AnyFormApi = FormApi< any > +interface ValidateOpts { + // Useful in FormGroup where validation doesn't update form error map + dontUpdateFormErrorMap?: boolean + // Filter which field names to validate, useful for FormGroup validation to filter out fields that don't start with the FormGroup name + filterFieldNames?: (fieldName: DeepKeys) => boolean +} + /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. @@ -1654,6 +1661,7 @@ export class FormApi< */ validateSync = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< @@ -1709,11 +1717,17 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - const allFieldsToProcess = new Set([ + let allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) + if (validateOpts?.filterFieldNames) { + allFieldsToProcess = new Set( + [...allFieldsToProcess].filter(validateOpts.filterFieldNames), + ) + } + for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && @@ -1765,15 +1779,17 @@ export class FormApi< } } - // 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 (!validateOpts?.dontUpdateFormErrorMap) { + // 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) { @@ -1781,6 +1797,10 @@ export class FormApi< } } + if (validateOpts?.dontUpdateFormErrorMap) { + return + } + /** * 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 @@ -1830,6 +1850,7 @@ export class FormApi< */ validateAsync = async ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): Promise< FormErrorMapFromValidator< TFormData, @@ -1920,9 +1941,13 @@ export class FormApi< } const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + let fields: DeepKeys[] = Object.keys(this.state.fieldMeta) + + if (validateOpts?.filterFieldNames) { + fields = fields.filter(validateOpts.filterFieldNames) + } + + for (const field of fields) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1965,13 +1990,15 @@ export class FormApi< } } - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } resolve( fieldErrorsFromFormValidators @@ -2030,6 +2057,7 @@ export class FormApi< */ validate = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): | FormErrorMapFromValidator< TFormData, @@ -2058,14 +2086,17 @@ export class FormApi< > > => { // Attempt to sync validate first - const { hasErrored, fieldsErrorMap } = this.validateSync(cause) + const { hasErrored, fieldsErrorMap } = this.validateSync( + cause, + validateOpts, + ) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation - return this.validateAsync(cause) + return this.validateAsync(cause, validateOpts) } // Needs to edgecase in the React adapter specifically to avoid type errors diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts new file mode 100644 index 000000000..3fb285ea9 --- /dev/null +++ b/packages/form-core/src/FormGroupApi.ts @@ -0,0 +1,311 @@ +import { batch, createStore } from '@tanstack/store' +import type { FormApi, FormListeners } from './FormApi' +import type { Store } from '@tanstack/store' +import type { FieldValidators } from './FieldApi' +import type { ValidationCause, ValidationError } from './types' + +/** + * An object representing the current state of the form group. + */ +type BaseFormGroupState = { + 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 +} + +function getDefaultFormGroupState( + defaultState: Partial, +): BaseFormGroupState { + return { + isSubmitted: defaultState.isSubmitted ?? false, + isSubmitting: defaultState.isSubmitting ?? false, + isValidating: defaultState.isValidating ?? false, + submissionAttempts: defaultState.submissionAttempts ?? 0, + isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, + } +} + +interface FormGroupOptions { + name: TName + onGroupSubmit?: (props: { + value: unknown + formApi: FormApi + meta: unknown + }) => any | Promise + onGroupSubmitInvalid?: (props: { + value: unknown + formApi: FormApi + meta: unknown + }) => void + validators?: FieldValidators< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + + /** + * A list of listeners which attach to the corresponding events + */ + listeners?: FormListeners< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + + // TODO: Does this even make sense to be here given that fields should inform the default state of a group? + /** + * The default state for the form group. + */ + defaultState?: Partial +} + +interface FormGroupApiOptions extends FormGroupOptions { + form: FormApi +} + +export class FormGroupApi { + options!: FormGroupApiOptions + + baseStore!: Store + + constructor(opts?: FormGroupApiOptions) { + this.handleSubmit = this.handleSubmit.bind(this) + + const baseStoreVal: BaseFormGroupState = getDefaultFormGroupState({ + ...(opts?.defaultState as any), + }) + + this.baseStore = createStore(baseStoreVal) as never + + this.update(opts) + } + + update = (options?: FormGroupApiOptions) => { + if (!options) return + + this.options = options + } + + mount() { + return () => {} + } + + _isFieldNamePartOfGroup = (fieldName: string) => { + // TODO: Does this `startWith` capture sub-field names properly? Probably not. :( + return fieldName.startsWith(this.options.name) + } + + _getRelatedFieldInfos = () => { + return Object.entries(this.options.form.fieldInfo).reduce( + (prev, [fieldName, fieldInfo]) => { + if (this._isFieldNamePartOfGroup(fieldName) && fieldInfo) { + prev[fieldName] = fieldInfo + } + return prev + }, + {} as typeof this.options.form.fieldInfo, + ) + } + + _isFieldsValid = () => { + return Object.values(this._getRelatedFieldInfos()).every( + (field) => field && field.instance && field.instance.state.meta.isValid, + ) + } + + /** + * Validates all fields using the correct handlers for a given validation cause. + */ + validateAllFields = async (cause: ValidationCause) => { + const fieldValidationPromises: Promise[] = [] as any + batch(() => { + void Object.values(this._getRelatedFieldInfos()).forEach((field) => { + if (!field || !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() + } + + _handleSubmit = async (): 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._getRelatedFieldInfos()).forEach((field) => { + if (!field || !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 })) + } + }) + }) + + // // TODO: Add support for meta + // const submitMetaArg = + // submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + + this.baseStore.setState((d) => ({ ...d, isSubmitting: true })) + + const done = () => { + this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false })) + } + + await this.validateAllFields('submit') + + // Fields are invalid, do not submit + if (!this._isFieldsValid()) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: 0 /* this.state.values */, + formApi: this.options.form, + meta: {} as never /* submitMetaArg */, + }) + + return + } + + await this.options.form.validate('submit', { + dontUpdateFormErrorMap: true, + filterFieldNames: this._isFieldNamePartOfGroup as never, + }) + + // Form is invalid, do not submit + if (!this.options.form.state.isValid) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: 0 /* this.state.values */, + formApi: this.options.form, + meta: {} as never /* submitMetaArg */, + }) + + return + } + + // TODO: Handle validators on the FormGroup itself + // await this.validate('submit') + // + // if (!this.state.isValid) { + // done() + // + // this.options.onGroupSubmitInvalid?.({ + // value: 0 /* this.state.values */, + // formApi: this.options.form, + // meta: {} as never /* submitMetaArg */, + // }) + // + // return + // } + + batch(() => { + void Object.values(this._getRelatedFieldInfos()).forEach((field) => { + if (!field || !field.instance) return + field.instance.options.listeners?.onGroupSubmit?.({ + value: field.instance.state.value, + fieldApi: field.instance, + }) + }) + }) + + this.options.listeners?.onSubmit?.({ + formApi: this.options.form, + meta: {} as never /* submitMetaArg */, + }) + + try { + await this.options.onGroupSubmit?.({ + value: 0, + formApi: this.options.form, + meta: {}, + }) + + // Run the submit code + await this.options.onGroupSubmit?.({ + value: 0, // this.state.values, + formApi: this.options.form, + meta: {}, // submitMetaArg, + }) + + batch(() => { + this.baseStore.setState((prev) => ({ + ...prev, + isSubmitted: true, + isSubmitSuccessful: true, // Set isSubmitSuccessful to true on successful submission + })) + + done() + }) + } catch (err) { + this.baseStore.setState((prev) => ({ + ...prev, + isSubmitSuccessful: false, // Ensure isSubmitSuccessful is false if an error occurs + })) + + done() + + throw err + } + } + + handleSubmit(): Promise { + return this._handleSubmit() + } +} diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index 94c0f3eea..b7fd096ce 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -1,5 +1,6 @@ export * from './FormApi' export * from './FieldApi' +export * from './FormGroupApi' export * from './utils' export * from './util-types' export * from './types' diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts new file mode 100644 index 000000000..b8845178b --- /dev/null +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { FieldApi, FormApi, FormGroupApi } from '../src/index' + +describe('form group api', () => { + it('should allow a submission without submitting the form', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmit).toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator and throw away other unrelated fields errors', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + step2: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it.todo('Should handle validations on form groups themselves') + it.todo('Should handle submit meta args') +}) From dbc7ff61a2b7dbf338a16c7a1f750988da109d4b Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 03:43:04 -0700 Subject: [PATCH 02/19] chore: initial (and wrong) per-form-group-validation This is wrong because instead of keeping the state inside of `FormGroupApi` itself, we need to follow the same pattern of `FieldApi` and keep the state in `FormApi` and reference that state in a derived way. --- packages/form-core/src/FormGroupApi.ts | 257 +++++++++++++++++- .../form-core/src/standardSchemaValidator.ts | 6 +- 2 files changed, 258 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 3fb285ea9..63fe25189 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1,13 +1,82 @@ import { batch, createStore } from '@tanstack/store' -import type { FormApi, FormListeners } from './FormApi' +import { getSyncValidatorArray } from './utils' +import { defaultValidationLogic } from './ValidationLogic' +import { + isStandardSchemaValidator, + standardSchemaValidators, +} from './standardSchemaValidator' +import type { ValidationLogicFn } from './ValidationLogic' +import type { TStandardSchemaValidatorValue } from './standardSchemaValidator' +import type { + FieldValidators, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, +} from './FieldApi' +import type { + ValidationCause, + ValidationError, + ValidationErrorMap, +} from './types' +import type { + AnyFormApi, + FormApi, + FormAsyncValidateOrFn, + FormListeners, + FormValidateFn, + FormValidateOrFn, +} from './FormApi' import type { Store } from '@tanstack/store' -import type { FieldValidators } from './FieldApi' -import type { ValidationCause, ValidationError } from './types' + +/** + * TODO: Add derived state for `errors` array derived from `errorMap` + */ /** * An object representing the current state of the form group. */ type BaseFormGroupState = { + /** + * The error map for the group itself. + */ + errorMap: ValidationErrorMap< + UnwrapFieldValidateOrFn, + UnwrapFieldValidateOrFn< + /* TName, TOnChange, TFormOnChange */ any, + any, + any + >, + UnwrapFieldAsyncValidateOrFn< + /* TName, TOnChangeAsync, TFormOnChangeAsync */ any, + any, + any + >, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn< + /* TName, TOnBlurAsync, TFormOnBlurAsync */ any, + any, + any + >, + UnwrapFieldValidateOrFn< + /* TName, TOnSubmit, TFormOnSubmit */ any, + any, + any + >, + UnwrapFieldAsyncValidateOrFn< + /* TName, TOnSubmitAsync, TFormOnSubmitAsync */ any, + any, + any + >, + UnwrapFieldValidateOrFn< + /* TName, TOnDynamic, TFormOnDynamic */ any, + any, + any + >, + UnwrapFieldAsyncValidateOrFn< + /* TName, TOnDynamicAsync, TFormOnDynamicAsync */ any, + any, + any + > + > isSubmitting: boolean /** * A boolean indicating if the `onSubmit` function has completed successfully. @@ -35,6 +104,12 @@ function getDefaultFormGroupState( defaultState: Partial, ): BaseFormGroupState { return { + // TODO: I think we need to handle the scenario where a Group is rendered for the first time and needs to inherit errors from the initial state of the fields... + // Maybe? + // + // TODO: Wait, but that doesn't make sense, because in JSX it would render the form group before the fields initialize and generate errors from the initial state of the fields... + // So we might need to use another derived state for errorMaps to merge with the form + fields? Ugh. I can't wait for v2 to simplify our error handling drastically. + errorMap: defaultState.errorMap ?? {}, isSubmitted: defaultState.isSubmitted ?? false, isSubmitting: defaultState.isSubmitting ?? false, isValidating: defaultState.isValidating ?? false, @@ -70,6 +145,8 @@ interface FormGroupOptions { any > + validationLogic?: ValidationLogicFn + /** * A list of listeners which attach to the corresponding events */ @@ -103,6 +180,10 @@ export class FormGroupApi { baseStore!: Store + get state() { + return this.baseStore.state + } + constructor(opts?: FormGroupApiOptions) { this.handleSubmit = this.handleSubmit.bind(this) @@ -176,6 +257,150 @@ export class FormGroupApi { return fieldErrorMapMap.flat() } + /** + * @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 + } + + /** + * TODO: This code is mostly copied from FormApi, we should refactor to share + * + * This does not need to validate fields or the base form, as that's done elsewhere + * + * @private + */ + validateSync = (cause: ValidationCause) => { + const validates = getSyncValidatorArray(cause, { + ...this.options, + form: this, + validationLogic: this.options.validationLogic || defaultValidationLogic, + }) + + let hasErrored = false as boolean + + batch(() => { + for (const validateObj of validates) { + if (!validateObj.validate) continue + + const rawError = this.runValidator({ + validate: validateObj.validate, + value: { + value: 0 /* this.state.values */, + formApi: this.options.form as never, + validationSource: 'field', + }, + type: 'validate', + }) + + // TODO: Support form group error maps like so: + /* + { + group: "Error on group", + fields: { + firstName: "Other error" + } + } + */ + // const { formError, fieldErrors } = normalizeError(rawError) + + const groupError = normalizeError(rawError) + const errorMapKey = getErrorMapKey(validateObj.cause) + + if (this.state.errorMap[errorMapKey] !== groupError) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: groupError, + }, + })) + } + + if (groupError /* || 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 ( + 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 ( + this.state.errorMap[serverErrKey] && + cause !== 'server' && + !hasErrored + ) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [serverErrKey]: undefined, + }, + })) + } + }) + + return { hasErrored } + } + + /** + * @private + */ + validate = ( + cause: ValidationCause, + // TODO: Handle return type? + ) => { + // 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) + } + _handleSubmit = async (): Promise => { this.baseStore.setState((old) => ({ ...old, @@ -309,3 +534,29 @@ export class FormGroupApi { return this._handleSubmit() } } + +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' + } +} diff --git a/packages/form-core/src/standardSchemaValidator.ts b/packages/form-core/src/standardSchemaValidator.ts index ae4b93c35..764852856 100644 --- a/packages/form-core/src/standardSchemaValidator.ts +++ b/packages/form-core/src/standardSchemaValidator.ts @@ -86,8 +86,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, async validateAsync( @@ -101,8 +102,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, } From 1a22f520b8b1d76fda8ad50eb62bb165612d28e7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 04:57:16 -0700 Subject: [PATCH 03/19] chore: create a common FieldLikeAPI to adopt in form groups shortly --- packages/form-core/src/FieldApi.ts | 618 ++++++--------------- packages/form-core/src/FieldGroupApi.ts | 6 +- packages/form-core/src/FormApi.ts | 112 ++-- packages/form-core/src/types.ts | 709 +++++++++++++++++++++++- 4 files changed, 899 insertions(+), 546 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 70ba9d9ab..662cc3d42 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -12,29 +12,32 @@ import { mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' +import type { + FieldInfo, + FieldLikeAPI, + FieldLikeApiOptions, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, + ListenerCause, + UpdateMetaOptions, + ValidationCause, + ValidationError, + ValidationErrorMap, +} from './types' import type { ReadonlyStore } from '@tanstack/store' -import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types' +import type { DeepKeys, DeepValue } from './util-types' import type { StandardSchemaV1, StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' import type { - FieldInfo, - FormApi, FormAsyncValidateOrFn, FormValidateAsyncFn, FormValidateFn, FormValidateOrFn, } from './FormApi' -import type { - ListenerCause, - UpdateMetaOptions, - ValidationCause, - ValidationError, - ValidationErrorMap, - ValidationErrorMapSource, -} from './types' import type { AsyncValidator, SyncValidator, Updater } from './utils' /** @@ -387,10 +390,7 @@ export interface FieldListeners< onGroupSubmit?: FieldListenerFn } -/** - * An object type representing the options for a field in a form. - */ -export interface FieldOptions< +interface FieldExtraOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, @@ -412,22 +412,6 @@ export interface FieldOptions< | undefined | FieldAsyncValidateOrFn, > { - /** - * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. - */ - name: TName - /** - * An optional default value for the field. - */ - defaultValue?: NoInfer - /** - * The default time to debounce async validation if there is not a more specific debounce time passed. - */ - asyncDebounceMs?: number - /** - * If `true`, always run async validation, even if there are errors emitted during synchronous validation. - */ - asyncAlways?: boolean /** * A list of validators to pass to the field */ @@ -445,11 +429,40 @@ export interface FieldOptions< TOnDynamic, TOnDynamicAsync > + /** - * An optional object with default metadata for the field. + * A list of listeners which attach to the corresponding events */ - defaultMeta?: Partial< - FieldMeta< + 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, @@ -461,32 +474,24 @@ export interface FieldOptions< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync, - any, - any, - any, - any, - any, - any, - any, - any, - any - > - > - /** - * A list of listeners which attach to the corresponding events - */ - listeners?: FieldListeners - /** - * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. - */ - disableErrorFlat?: boolean -} + TOnDynamicAsync + >, + FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} -/** - * An object type representing the required options for the FieldApi class. - */ -export interface FieldApiOptions< +interface FieldApiOptions< in out TParentData, in out TName extends DeepKeys, in out TData extends DeepValue, @@ -536,402 +541,47 @@ export interface FieldApiOptions< | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, -> extends FieldOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync -> { - form: FormApi< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TParentSubmitMeta - > -} - -export type FieldMetaBase< - 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, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * A flag indicating whether the field has been touched. - */ - isTouched: boolean - /** - * A flag indicating whether the field has been blurred. - */ - isBlurred: boolean - /** - * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. - */ - isDirty: boolean - /** - * A map of errors related to the field value. - */ - errorMap: ValidationErrorMap< - UnwrapFieldValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn - > - - /** - * @private allows tracking the source of the errors in the error map - */ - errorSourceMap: ValidationErrorMapSource - /** - * A flag indicating whether the field is currently being validated. - */ - isValidating: boolean -} - -export type AnyFieldMetaBase = FieldMetaBase< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -export type FieldMetaDerived< - 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, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * An array of errors related to the field value. - */ - errors: Array< - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn< - TName, - TOnDynamicAsync, - TFormOnDynamicAsync - > - > - > - /** - * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. - */ - isPristine: boolean - /** - * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. - */ - isValid: boolean - /** - * A flag indicating whether the field's current value is the default value - */ - isDefaultValue: boolean -} - -export type AnyFieldMetaDerived = FieldMetaDerived< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -/** - * An object type representing the metadata of a field in a form. - */ -export type FieldMeta< - 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, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = FieldMetaBase< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync -> & - FieldMetaDerived< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync - > - -export type AnyFieldMeta = FieldMeta< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any > - -/** - * An object type representing the state of a field. - */ -export type FieldState< - 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, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * The current value of the field. - */ - value: TData - /** - * The current metadata of the field. - */ - meta: FieldMeta< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync - > -} + 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 @@ -1028,6 +678,44 @@ export class FieldApi< | 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. @@ -1093,7 +781,7 @@ export class FieldApi< * The field state store. */ store!: ReadonlyStore< - FieldState< + FieldLikeState< TParentData, TName, TData, @@ -1172,7 +860,7 @@ export class FieldApi< this.store = createStore( ( prevVal: - | FieldState< + | FieldLikeState< TParentData, TName, TData, @@ -1222,7 +910,7 @@ export class FieldApi< return { value, meta, - } as FieldState< + } as FieldLikeState< TParentData, TName, TData, @@ -1491,7 +1179,7 @@ export class FieldApi< */ setMeta = ( updater: Updater< - FieldMetaBase< + FieldLikeMetaBase< TParentData, TName, TData, @@ -1654,6 +1342,10 @@ export class FieldApi< 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)) { @@ -2173,6 +1865,16 @@ export class FieldApi< }) } } + + /** + * @private + */ + triggerOnSubmitListener() { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + fieldApi: this, + }) + } } function normalizeError(rawError?: ValidationError) { diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 6cd9d0976..24f78b8ef 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -7,7 +7,7 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMetaBase, FieldOptions } from './FieldApi' +import type { AnyFieldLikeMetaBase, FieldOptions } from './FieldApi' import type { DeepKeys, DeepKeysOfType, @@ -15,7 +15,7 @@ import type { FieldsMap, } from './util-types' import type { - FieldManipulator, + FormLikeAPI, UpdateMetaOptions, ValidationCause, } from './types' @@ -127,7 +127,7 @@ export class FieldGroupApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The form that called this field group. */ diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index b99f0fdb5..0e727a435 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -30,15 +30,13 @@ import type { StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' -import type { - AnyFieldApi, - AnyFieldMeta, - AnyFieldMetaBase, - FieldApi, -} from './FieldApi' -import type { +import type { AnyFieldApi } from './FieldApi' +import { + AnyFieldLikeMeta, + AnyFieldLikeMetaBase, ExtractGlobalFormError, - FieldManipulator, + FieldInfo, + FormLikeAPI, FormValidationError, FormValidationErrorMap, GlobalFormValidationError, @@ -503,44 +501,6 @@ export type ValidationMeta = { 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. */ @@ -583,7 +543,7 @@ export type BaseFormState< /** * A record of field metadata for each field in the form, not including the derived properties, like `errors` and such */ - fieldMetaBase: Partial, AnyFieldMetaBase>> + fieldMetaBase: Partial, AnyFieldLikeMetaBase>> /** * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. * @@ -712,7 +672,7 @@ export type DerivedFormState< /** * A record of field metadata for each field in the form. */ - fieldMeta: Partial, AnyFieldMeta>> + fieldMeta: Partial, AnyFieldLikeMeta>> } export interface FormState< @@ -883,7 +843,7 @@ export class FormApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The options for the form. */ @@ -951,7 +911,6 @@ export class FormApi< * A record of field information for each field in the form. */ fieldInfo: Partial, FieldInfo>> = {} - get state() { return this.store.state } @@ -1042,7 +1001,7 @@ export class FormApi< } const existingFieldMeta = baseStoreVal.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined baseStoreVal.fieldMetaBase[fieldName as never] = { isTouched: false, isValidating: false, @@ -1057,7 +1016,7 @@ export class FormApi< ...(existingFieldMeta?.['errorMap'] ?? {}), [errKey as never]: fieldErr, }, - } satisfies AnyFieldMetaBase as never + } satisfies AnyFieldLikeMetaBase as never } } } @@ -1081,7 +1040,7 @@ export class FormApi< | undefined = undefined this.fieldMetaDerived = createStore( - (prevVal: Record, AnyFieldMeta> | undefined) => { + (prevVal: Record, AnyFieldLikeMeta> | undefined) => { const currBaseStore = this.baseStore.get() let originalMetaCount = 0 @@ -1105,11 +1064,11 @@ export class FormApi< ) as Array) { const currBaseMeta = currBaseStore.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase + ] as AnyFieldLikeMetaBase const prevBaseMeta = prevBaseStore?.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] @@ -1167,7 +1126,7 @@ export class FormApi< isPristine: isFieldPristine, isValid: isFieldValid, isDefaultValue: isDefaultValue, - } satisfies AnyFieldMeta as AnyFieldMeta + } satisfies AnyFieldLikeMeta as AnyFieldLikeMeta } if (!Object.keys(currBaseStore.fieldMetaBase).length) return fieldMeta @@ -1222,7 +1181,7 @@ export class FormApi< // Computed state const fieldMetaValues = Object.values(currFieldMeta).filter( Boolean, - ) as AnyFieldMeta[] + ) as AnyFieldLikeMeta[] const isFieldsValidating = fieldMetaValues.some( (field) => field.isValidating, @@ -1572,7 +1531,7 @@ export class FormApi< ) // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1647,7 +1606,7 @@ export class FormApi< } // If the field is not touched (same logic as in validateAllFields) - if (!fieldInstance.state.meta.isTouched) { + if (!fieldInstance.store.state.meta.isTouched) { // Mark it as touched fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1972,10 +1931,8 @@ export class FormApi< previousErrorValue: currentErrorMap?.[errorMapKey], }) - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (currentErrorMap?.[errorMapKey] !== newErrorValue) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { @@ -2124,7 +2081,7 @@ export class FormApi< (field) => { if (!field.instance) return // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -2166,8 +2123,8 @@ export class FormApi< submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'validateAllFields', - errors: (Object.values(this.state.fieldMeta) as AnyFieldMeta[]) - .map((meta: AnyFieldMeta) => meta.errors) + errors: (Object.values(this.state.fieldMeta) as AnyFieldLikeMeta[]) + .map((meta) => meta.errors) .flat(), }) return @@ -2199,10 +2156,7 @@ export class FormApi< batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { - field.instance?.options.listeners?.onSubmit?.({ - value: field.instance.state.value, - fieldApi: field.instance, - }) + field.instance?.triggerOnSubmitListener() }, ) }) @@ -2264,7 +2218,7 @@ export class FormApi< */ getFieldMeta = >( field: TField, - ): AnyFieldMeta | undefined => { + ): AnyFieldLikeMeta | undefined => { return this.state.fieldMeta[field] } @@ -2292,7 +2246,7 @@ export class FormApi< */ setFieldMeta = >( field: TField, - updater: Updater, + updater: Updater, ) => { this.baseStore.setState((prev) => { return { @@ -2312,15 +2266,15 @@ export class FormApi< * resets every field's meta */ resetFieldMeta = >( - fieldMeta: Partial>, - ): Partial> => { + fieldMeta: Partial>, + ): Partial> => { return Object.keys(fieldMeta).reduce( (acc, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, - {} as Partial>, + {} as Partial>, ) } @@ -2730,12 +2684,12 @@ export class FormApi< fields: Object.entries(this.state.fieldMeta).reduce( (acc, [fieldName, fieldMeta]) => { if ( - Object.keys(fieldMeta as AnyFieldMeta).length && - (fieldMeta as AnyFieldMeta).errors.length + Object.keys(fieldMeta as AnyFieldLikeMeta).length && + (fieldMeta as AnyFieldLikeMeta).errors.length ) { acc[fieldName as DeepKeys] = { - errors: (fieldMeta as AnyFieldMeta).errors, - errorMap: (fieldMeta as AnyFieldMeta).errorMap, + errors: (fieldMeta as AnyFieldLikeMeta).errors, + errorMap: (fieldMeta as AnyFieldLikeMeta).errorMap, } } diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index ff5824283..2137ec40c 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,6 +1,24 @@ -import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' -import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import type { + FieldAsyncValidateOrFn, + FieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, +} from './FieldApi' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + UnwrapOneLevelOfArray, +} from './util-types' import type { Updater } from './utils' +import type { + AnyFormApi, + FormApi, + FormAsyncValidateOrFn, + FormValidateOrFn, + ValidationMeta, +} from './FormApi' +import type { ReadonlyStore } from '@tanstack/store' export type ValidationError = unknown @@ -146,9 +164,8 @@ export interface UpdateMetaOptions { /** * @private - * A list of field manipulation methods that a form-like API must implement. */ -export interface FieldManipulator { +export interface FormLikeAPI { /** * Validates all fields using the correct handlers for a given validation cause. */ @@ -191,14 +208,14 @@ export interface FieldManipulator { */ getFieldMeta: >( field: TField, - ) => AnyFieldMeta | undefined + ) => AnyFieldLikeMeta | undefined /** * Updates the metadata of the specified field. */ setFieldMeta: >( field: TField, - updater: Updater, + updater: Updater, ) => void /** @@ -292,3 +309,683 @@ export interface FieldManipulator { */ resetField: >(field: TField) => void } + +/** + * @private + */ +export type FieldLikeMetaBase< + 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, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * A flag indicating whether the field has been touched. + */ + isTouched: boolean + /** + * A flag indicating whether the field has been blurred. + */ + isBlurred: boolean + /** + * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. + */ + isDirty: boolean + /** + * A map of errors related to the field value. + */ + errorMap: ValidationErrorMap< + UnwrapFieldValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn + > + + /** + * @private allows tracking the source of the errors in the error map + */ + errorSourceMap: ValidationErrorMapSource + /** + * A flag indicating whether the field is currently being validated. + */ + isValidating: boolean +} + +/** + * @private + */ +export type AnyFieldLikeMetaBase = FieldLikeMetaBase< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * @private + */ +export type FieldLikeMetaDerived< + 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, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * An array of errors related to the field value. + */ + errors: Array< + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnDynamicAsync, + TFormOnDynamicAsync + > + > + > + /** + * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. + */ + isPristine: boolean + /** + * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. + */ + isValid: boolean + /** + * A flag indicating whether the field's current value is the default value + */ + isDefaultValue: boolean +} + +/** + * @private + * An object type representing the metadata of a field in a form. + */ +export type FieldLikeMeta< + 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, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = FieldLikeMetaBase< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync +> & + FieldLikeMetaDerived< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + +/** + * @private + */ +export type AnyFieldLikeMeta = FieldLikeMeta< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * @private + * An object type representing the state of a field. + */ +export type FieldLikeState< + 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, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * The current value of the field. + */ + value: TData + /** + * The current metadata of the field. + */ + meta: FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > +} + +/** + * @private + * An object type representing the options for a field in a form. + */ +export interface FieldLikeOptions< + 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, +> { + /** + * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. + */ + name: TName + /** + * An optional default value for the field. + */ + defaultValue?: NoInfer + /** + * The default time to debounce async validation if there is not a more specific debounce time passed. + */ + asyncDebounceMs?: number + /** + * If `true`, always run async validation, even if there are errors emitted during synchronous validation. + */ + asyncAlways?: boolean + /** + * An optional object with default metadata for the field. + */ + defaultMeta?: Partial< + FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + > + /** + * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. + */ + disableErrorFlat?: boolean +} + +/** + * @private + * An object type representing the required options for the FieldApi class. + */ +export interface FieldLikeApiOptions< + 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 FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync +> { + form: FormApi< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +} + +/** + * @private + */ +export interface FieldLikeAPI< + 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, + TExtraOptions = {}, +> { + form: AnyFormApi + options: FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + TExtraOptions + 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 field name. + */ + name: TName + mount: () => () => void + + setValue: (updater: Updater, options?: UpdateMetaOptions) => void + getMeta: () => AnyFieldLikeMeta + setMeta: (updater: Updater) => void + getInfo: () => FieldInfo + validate: ( + cause: ValidationCause, + opts?: { skipFormValidation?: boolean }, + ) => ValidationError[] | Promise + /** + * @private + */ + triggerOnChangeListener: () => void + /** + * @private + */ + triggerOnSubmitListener: () => void +} + +/** + * @private + */ +export interface FieldInfo { + instance: FieldLikeAPI< + TParentData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > | null + validationMetaMap: Record +} From 47759963507f1277c11a972f132f7bd1db55663d Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 05:45:02 -0700 Subject: [PATCH 04/19] chore: implement FieldLike API on FormGroup --- packages/form-core/src/FieldApi.ts | 145 +-- packages/form-core/src/FormGroupApi.ts | 1123 +++++++++++++++++++++--- packages/form-core/src/types.ts | 167 +++- 3 files changed, 1160 insertions(+), 275 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 662cc3d42..d11ab7eee 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -13,13 +13,18 @@ import { } from './utils' import { defaultValidationLogic } from './ValidationLogic' import type { + FieldAsyncValidateOrFn, + FieldErrorMapFromValidator, FieldInfo, FieldLikeAPI, FieldLikeApiOptions, FieldLikeMetaBase, FieldLikeOptions, FieldLikeState, + FieldValidateOrFn, ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, UpdateMetaOptions, ValidationCause, ValidationError, @@ -29,53 +34,11 @@ import type { ReadonlyStore } from '@tanstack/store' import type { DeepKeys, DeepValue } from './util-types' import type { StandardSchemaV1, - StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' -import type { - FormAsyncValidateOrFn, - FormValidateAsyncFn, - FormValidateFn, - FormValidateOrFn, -} from './FormApi' +import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' import type { AsyncValidator, SyncValidator, Updater } from './utils' -/** - * @private - */ -// TODO: Add the `Unwrap` type to the errors -type FieldErrorMapFromValidator< - TFormData, - 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, -> = Partial< - Record< - DeepKeys, - ValidationErrorMap< - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync - > - > -> - /** * @private */ @@ -114,55 +77,6 @@ export type FieldValidateFn< > }) => unknown -/** - * @private - */ -export type FieldValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateFn - | StandardSchemaV1 - -type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } - -type UnwrapFormValidateOrFnForInner< - TValidateOrFn extends undefined | FormValidateOrFn, -> = [TValidateOrFn] extends [FormValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldValidateOrFn, - TFormValidateOrFn extends undefined | FormValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -202,53 +116,6 @@ export type FieldValidateAsyncFn< signal: AbortSignal }) => unknown | Promise -/** - * @private - */ -export type FieldAsyncValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateAsyncFn - | StandardSchemaV1 - -type UnwrapFormAsyncValidateOrFnForInner< - TValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = [TValidateOrFn] extends [FormValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldAsyncValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldAsyncValidateOrFn, - TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 63fe25189..159d97978 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1,136 +1,185 @@ import { batch, createStore } from '@tanstack/store' -import { getSyncValidatorArray } from './utils' +import { evaluate, getSyncValidatorArray, mergeOpts } from './utils' import { defaultValidationLogic } from './ValidationLogic' import { isStandardSchemaValidator, standardSchemaValidators, } from './standardSchemaValidator' -import type { ValidationLogicFn } from './ValidationLogic' -import type { TStandardSchemaValidatorValue } from './standardSchemaValidator' +import { defaultFieldMeta } from './metaHelper' +import type { Updater } from './utils' +import type { ReadonlyStore } from '@tanstack/store' import type { - FieldValidators, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, -} from './FieldApi' + StandardSchemaV1, + StandardSchemaV1Issue, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' import type { + FieldLikeAPI, + FieldLikeApiOptions, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, + ListenerCause, + UpdateMetaOptions, ValidationCause, ValidationError, ValidationErrorMap, } from './types' import type { AnyFormApi, - FormApi, FormAsyncValidateOrFn, - FormListeners, + FormValidateAsyncFn, FormValidateFn, FormValidateOrFn, } from './FormApi' -import type { Store } from '@tanstack/store' +import type { DeepKeys, DeepValue } from './util-types' /** - * TODO: Add derived state for `errors` array derived from `errorMap` + * @private */ +// TODO: Add the `Unwrap` type to the errors +type FormGroupErrorMapFromValidator< + TFormData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends undefined | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends undefined | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> = Partial< + Record< + DeepKeys, + ValidationErrorMap< + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + > +> /** - * An object representing the current state of the form group. + * @private */ -type BaseFormGroupState = { - /** - * The error map for the group itself. - */ - errorMap: ValidationErrorMap< - UnwrapFieldValidateOrFn, - UnwrapFieldValidateOrFn< - /* TName, TOnChange, TFormOnChange */ any, - any, - any - >, - UnwrapFieldAsyncValidateOrFn< - /* TName, TOnChangeAsync, TFormOnChangeAsync */ any, - any, - any - >, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn< - /* TName, TOnBlurAsync, TFormOnBlurAsync */ any, - any, - any - >, - UnwrapFieldValidateOrFn< - /* TName, TOnSubmit, TFormOnSubmit */ any, - any, - any - >, - UnwrapFieldAsyncValidateOrFn< - /* TName, TOnSubmitAsync, TFormOnSubmitAsync */ any, - any, - any - >, - UnwrapFieldValidateOrFn< - /* TName, TOnDynamic, TFormOnDynamic */ any, - any, - any - >, - UnwrapFieldAsyncValidateOrFn< - /* TName, TOnDynamicAsync, TFormOnDynamicAsync */ any, - any, - any - > +export type FormGroupValidateFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + 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 group 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 > - 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 -} +}) => unknown -function getDefaultFormGroupState( - defaultState: Partial, -): BaseFormGroupState { - return { - // TODO: I think we need to handle the scenario where a Group is rendered for the first time and needs to inherit errors from the initial state of the fields... - // Maybe? - // - // TODO: Wait, but that doesn't make sense, because in JSX it would render the form group before the fields initialize and generate errors from the initial state of the fields... - // So we might need to use another derived state for errorMaps to merge with the form + fields? Ugh. I can't wait for v2 to simplify our error handling drastically. - errorMap: defaultState.errorMap ?? {}, - isSubmitted: defaultState.isSubmitted ?? false, - isSubmitting: defaultState.isSubmitting ?? false, - isValidating: defaultState.isValidating ?? false, - submissionAttempts: defaultState.submissionAttempts ?? 0, - isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, - } -} +/** + * @private + */ +export type FormGroupValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateFn + | StandardSchemaV1 + +type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } + +type UnwrapFormValidateOrFnForInner< + TValidateOrFn extends undefined | FormValidateOrFn, +> = [TValidateOrFn] extends [FormValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFormGroupValidateOrFn< + TName extends string, + TValidateOrFn extends undefined | FormGroupValidateOrFn, + TFormValidateOrFn extends undefined | FormValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FormGroupValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) -interface FormGroupOptions { - name: TName - onGroupSubmit?: (props: { - value: unknown - formApi: FormApi - meta: unknown - }) => any | Promise - onGroupSubmitInvalid?: (props: { - value: unknown - formApi: FormApi - meta: unknown - }) => void - validators?: FieldValidators< +/** + * @private + */ +export type FormGroupValidateAsyncFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (options: { + value: TData + groupApi: FormGroupApi< + 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 group validate function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, any, any, any, @@ -144,13 +193,80 @@ interface FormGroupOptions { any, any > + signal: AbortSignal +}) => unknown | Promise - validationLogic?: ValidationLogicFn +/** + * @private + */ +export type FormGroupAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateAsyncFn + | StandardSchemaV1 + +type UnwrapFormAsyncValidateOrFnForInner< + TValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = [TValidateOrFn] extends [FormValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFormGroupAsyncValidateOrFn< + TName extends string, + TValidateOrFn extends undefined | FormGroupAsyncValidateOrFn, + TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FormGroupValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) - /** - * A list of listeners which attach to the corresponding events - */ - listeners?: FormListeners< +/** + * @private + */ +export type FormGroupListenerFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + 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 group listener function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, any, any, any, @@ -163,49 +279,742 @@ interface FormGroupOptions { any, any > +}) => void + +export interface FormGroupValidators< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> { + /** + * An optional function, that runs on the mount event of input. + */ + onMount?: TOnMount + /** + * An optional function, that runs on the change event of input. + * + * @example z.string().min(1) + */ + onChange?: TOnChange + /** + * 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?: TOnBlur + /** + * An optional property similar to `onBlur` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onBlurAsync?: TOnBlurAsync - // TODO: Does this even make sense to be here given that fields should inform the default state of a group? /** - * The default state for the form group. + * 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) */ - defaultState?: Partial + onSubmit?: TOnSubmit + /** + * An optional property similar to `onSubmit` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onSubmitAsync?: TOnSubmitAsync + onDynamic?: TOnDynamic + onDynamicAsync?: TOnDynamicAsync + onDynamicAsyncDebounceMs?: number } -interface FormGroupApiOptions extends FormGroupOptions { - form: FormApi +export interface FormGroupListeners< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> { + onChange?: FormGroupListenerFn + onChangeDebounceMs?: number + onBlur?: FormGroupListenerFn + onBlurDebounceMs?: number + onMount?: FormGroupListenerFn + onUnmount?: FormGroupListenerFn + onSubmit?: FormGroupListenerFn + onGroupSubmit?: FormGroupListenerFn } -export class FormGroupApi { - options!: FormGroupApiOptions +interface FormGroupExtraOptions< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> { + /** + * A list of validators to pass to the field + */ + validators?: FormGroupValidators< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > - baseStore!: Store + /** + * A list of listeners which attach to the corresponding events + */ + listeners?: FormGroupListeners +} +/** + * 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 | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> + extends + FormGroupExtraOptions< + 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 + > {} + +interface FormGroupApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + 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 + >, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} + +export class FormGroupApi< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + 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, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > +> { + /** + * A reference to the form API instance. + */ + form: FormGroupApiOptions< + 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: FormGroupApiOptions< + 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.baseStore.state + return this.store.state + } + timeoutIds: { + validations: Record | null> + listeners: Record | null> + formListeners: Record | null> } - constructor(opts?: FormGroupApiOptions) { - this.handleSubmit = this.handleSubmit.bind(this) + constructor( + opts: FormGroupApiOptions< + 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, + } - const baseStoreVal: BaseFormGroupState = getDefaultFormGroupState({ - ...(opts?.defaultState as any), - }) + 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, + } - this.baseStore = createStore(baseStoreVal) as never + 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 + } - this.update(opts) + 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 + > + }, + ) + + this.handleSubmit = this.handleSubmit.bind(this) } - update = (options?: FormGroupApiOptions) => { - if (!options) return + /** + * Updates the field instance with new options. + */ + update = ( + opts: FormGroupApiOptions< + 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, + }) + } + } - this.options = options + if (!this.form.getFieldMeta(this.name)) { + this.form.setFieldMeta(this.name, this.state.meta) + } } mount() { + // TODO: Absorb from FieldApi return () => {} } + /** + * 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) + _isFieldNamePartOfGroup = (fieldName: string) => { // TODO: Does this `startWith` capture sub-field names properly? Probably not. :( return fieldName.startsWith(this.options.name) @@ -401,6 +1210,60 @@ export class FormGroupApi { return this.validateAsync(cause) } + /** + * @private + */ + triggerOnChangeListener = () => { + // // TODO: Solve typings with formListener getting a fieldApi vs a groupApi + // 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, + // groupApi: this, + // }) + // }, formDebounceMs) + // } else { + // this.form.options.listeners?.onChange?.({ + // formApi: this.form, + // groupApi: 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, + groupApi: this, + }) + }, fieldDebounceMs) + } else { + this.options.listeners?.onChange?.({ + value: this.state.value, + groupApi: this, + }) + } + } + + /** + * @private + */ + triggerOnSubmitListener() { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + groupApi: this, + }) + } + _handleSubmit = async (): Promise => { this.baseStore.setState((old) => ({ ...old, diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index 2137ec40c..437fa3487 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,9 +1,4 @@ -import type { - FieldAsyncValidateOrFn, - FieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, -} from './FieldApi' +import { FieldApi, FieldValidateAsyncFn, FieldValidateFn } from './FieldApi' import type { DeepKeys, DeepKeysOfType, @@ -15,10 +10,21 @@ import type { AnyFormApi, FormApi, FormAsyncValidateOrFn, + FormValidateAsyncFn, + FormValidateFn, FormValidateOrFn, ValidationMeta, } from './FormApi' import type { ReadonlyStore } from '@tanstack/store' +import type { + FormGroupAsyncValidateOrFn, + FormGroupValidateAsyncFn, + FormGroupValidateFn, +} from './FormGroupApi' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from './standardSchemaValidator' export type ValidationError = unknown @@ -310,6 +316,155 @@ export interface FormLikeAPI { resetField: >(field: TField) => void } +/** + * @private + */ +export type FieldAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateAsyncFn + | FormGroupValidateAsyncFn + | StandardSchemaV1 + +type UnwrapFormAsyncValidateOrFnForInner< + TValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = [TValidateOrFn] extends [FormValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldAsyncValidateOrFn< + TName extends string, + TValidateOrFn extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + +/** + * @private + */ +// TODO: Add the `Unwrap` type to the errors +export type FieldErrorMapFromValidator< + TFormData, + 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, +> = Partial< + Record< + DeepKeys, + ValidationErrorMap< + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + > +> + +/** + * @private + */ +export type FieldValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateFn + | FormGroupValidateFn + | StandardSchemaV1 + +type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } + +type UnwrapFormValidateOrFnForInner< + TValidateOrFn extends undefined | FormValidateOrFn, +> = [TValidateOrFn] extends [FormValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldValidateOrFn< + TName extends string, + TValidateOrFn extends undefined | FieldValidateOrFn, + TFormValidateOrFn extends undefined | FormValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + /** * @private */ From 756ddf840210853ba0feb4ba4c94548e55a09cd9 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 05:47:31 -0700 Subject: [PATCH 05/19] chore: revert changes to FormApi validation logic --- packages/form-core/src/FormApi.ts | 83 ++++++++++--------------------- 1 file changed, 27 insertions(+), 56 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 0e727a435..ee994060d 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -31,7 +31,7 @@ import type { TStandardSchemaValidatorValue, } from './standardSchemaValidator' import type { AnyFieldApi } from './FieldApi' -import { +import type { AnyFieldLikeMeta, AnyFieldLikeMetaBase, ExtractGlobalFormError, @@ -811,13 +811,6 @@ export type AnyFormApi = FormApi< any > -interface ValidateOpts { - // Useful in FormGroup where validation doesn't update form error map - dontUpdateFormErrorMap?: boolean - // Filter which field names to validate, useful for FormGroup validation to filter out fields that don't start with the FormGroup name - filterFieldNames?: (fieldName: DeepKeys) => boolean -} - /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. @@ -1620,7 +1613,6 @@ export class FormApi< */ validateSync = ( cause: ValidationCause, - validateOpts?: ValidateOpts, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< @@ -1676,17 +1668,11 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - let allFieldsToProcess = new Set([ + const allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) - if (validateOpts?.filterFieldNames) { - allFieldsToProcess = new Set( - [...allFieldsToProcess].filter(validateOpts.filterFieldNames), - ) - } - for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && @@ -1738,17 +1724,15 @@ export class FormApi< } } - if (!validateOpts?.dontUpdateFormErrorMap) { - // 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, - }, - })) - } + // 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) { @@ -1756,10 +1740,6 @@ export class FormApi< } } - if (validateOpts?.dontUpdateFormErrorMap) { - return - } - /** * 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 @@ -1809,7 +1789,6 @@ export class FormApi< */ validateAsync = async ( cause: ValidationCause, - validateOpts?: ValidateOpts, ): Promise< FormErrorMapFromValidator< TFormData, @@ -1900,13 +1879,9 @@ export class FormApi< } const errorMapKey = getErrorMapKey(validateObj.cause) - let fields: DeepKeys[] = Object.keys(this.state.fieldMeta) - - if (validateOpts?.filterFieldNames) { - fields = fields.filter(validateOpts.filterFieldNames) - } - - for (const field of fields) { + for (const field of Object.keys( + this.state.fieldMeta, + ) as DeepKeys[]) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1931,8 +1906,10 @@ export class FormApi< previousErrorValue: currentErrorMap?.[errorMapKey], }) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (currentErrorMap?.[errorMapKey] !== newErrorValue) { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + currentErrorMap?.[errorMapKey] !== newErrorValue + ) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { @@ -1947,15 +1924,13 @@ export class FormApi< } } - if (!validateOpts?.dontUpdateFormErrorMap) { - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) - } + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) resolve( fieldErrorsFromFormValidators @@ -2014,7 +1989,6 @@ export class FormApi< */ validate = ( cause: ValidationCause, - validateOpts?: ValidateOpts, ): | FormErrorMapFromValidator< TFormData, @@ -2043,17 +2017,14 @@ export class FormApi< > > => { // Attempt to sync validate first - const { hasErrored, fieldsErrorMap } = this.validateSync( - cause, - validateOpts, - ) + const { hasErrored, fieldsErrorMap } = this.validateSync(cause) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation - return this.validateAsync(cause, validateOpts) + return this.validateAsync(cause) } // Needs to edgecase in the React adapter specifically to avoid type errors From c553439b11112474b399d3896cf7bbfbdd4a1084 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 06:40:48 -0700 Subject: [PATCH 06/19] chore: fix type issues with validation kind --- packages/form-core/src/FormGroupApi.ts | 1112 +++++++++++------ packages/form-core/tests/FormGroupApi.spec.ts | 2 + 2 files changed, 766 insertions(+), 348 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 159d97978..f816c7a9c 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1,19 +1,29 @@ import { batch, createStore } from '@tanstack/store' -import { evaluate, getSyncValidatorArray, mergeOpts } from './utils' +import { + determineFieldLevelErrorSourceAndValue, + evaluate, + getAsyncValidatorArray, + getSyncValidatorArray, + mergeOpts, +} from './utils' import { defaultValidationLogic } from './ValidationLogic' import { isStandardSchemaValidator, standardSchemaValidators, } from './standardSchemaValidator' import { defaultFieldMeta } from './metaHelper' -import type { Updater } from './utils' -import type { ReadonlyStore } from '@tanstack/store' +import { FieldApi } from './FieldApi' +import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' +import type { AnyFieldApi } from './FieldApi' import type { StandardSchemaV1, - StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' +import type { AsyncValidator, SyncValidator, Updater } from './utils' +import type { ReadonlyStore, Store } from '@tanstack/store' import type { + FieldErrorMapFromValidator, + FieldInfo, FieldLikeAPI, FieldLikeApiOptions, FieldLikeMetaBase, @@ -25,51 +35,8 @@ import type { ValidationError, ValidationErrorMap, } from './types' -import type { - AnyFormApi, - FormAsyncValidateOrFn, - FormValidateAsyncFn, - FormValidateFn, - FormValidateOrFn, -} from './FormApi' import type { DeepKeys, DeepValue } from './util-types' -/** - * @private - */ -// TODO: Add the `Unwrap` type to the errors -type FormGroupErrorMapFromValidator< - TFormData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FormGroupValidateOrFn, - TOnChange extends undefined | FormGroupValidateOrFn, - TOnChangeAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - TOnBlur extends undefined | FormGroupValidateOrFn, - TOnBlurAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - TOnSubmit extends undefined | FormGroupValidateOrFn, - TOnSubmitAsync extends - | undefined - | FormGroupAsyncValidateOrFn, -> = Partial< - Record< - DeepKeys, - ValidationErrorMap< - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync - > - > -> - /** * @private */ @@ -104,6 +71,7 @@ export type FormGroupValidateFn< any, any, any, + any, any > }) => unknown @@ -119,44 +87,6 @@ export type FormGroupValidateOrFn< | FormGroupValidateFn | StandardSchemaV1 -type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } - -type UnwrapFormValidateOrFnForInner< - TValidateOrFn extends undefined | FormValidateOrFn, -> = [TValidateOrFn] extends [FormValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFormGroupValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FormGroupValidateOrFn, - TFormValidateOrFn extends undefined | FormValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FormGroupValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -191,6 +121,7 @@ export type FormGroupValidateAsyncFn< any, any, any, + any, any > signal: AbortSignal @@ -207,42 +138,6 @@ export type FormGroupAsyncValidateOrFn< | FormGroupValidateAsyncFn | StandardSchemaV1 -type UnwrapFormAsyncValidateOrFnForInner< - TValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = [TValidateOrFn] extends [FormValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFormGroupAsyncValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FormGroupAsyncValidateOrFn, - TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FormGroupValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -277,6 +172,7 @@ export type FormGroupListenerFn< any, any, any, + any, any > }) => void @@ -391,32 +287,56 @@ export interface FormGroupListeners< } interface FormGroupExtraOptions< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FormGroupValidateOrFn, - TOnChange extends + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends | undefined | FormGroupValidateOrFn, - TOnChangeAsync extends + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnBlur extends undefined | FormGroupValidateOrFn, - TOnBlurAsync extends + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnSubmit extends + in out TOnSubmit extends | undefined | FormGroupValidateOrFn, - TOnSubmitAsync extends + in out TOnSubmitAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnDynamic extends + in out TOnDynamic extends | undefined | FormGroupValidateOrFn, - TOnDynamicAsync extends + in out TOnDynamicAsync extends | undefined | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + 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, > { /** * A list of validators to pass to the field @@ -440,38 +360,135 @@ interface FormGroupExtraOptions< * A list of listeners which attach to the corresponding events */ listeners?: FormGroupListeners + + defaultState?: FormGroupState + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: 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` + */ + onGroupSubmit?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => any | Promise + /** + * Specify an action for scenarios where the user tries to submit an invalid form. + */ + onGroupSubmitInvalid?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => void } /** * 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 | FormGroupValidateOrFn, - TOnChange extends + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends | undefined | FormGroupValidateOrFn, - TOnChangeAsync extends + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnBlur extends undefined | FormGroupValidateOrFn, - TOnBlurAsync extends + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnSubmit extends + in out TOnSubmit extends | undefined | FormGroupValidateOrFn, - TOnSubmitAsync extends + in out TOnSubmitAsync extends | undefined | FormGroupAsyncValidateOrFn, - TOnDynamic extends + in out TOnDynamic extends | undefined | FormGroupValidateOrFn, - TOnDynamicAsync extends + in out TOnDynamicAsync extends | undefined | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + 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 FormGroupExtraOptions< @@ -486,7 +503,19 @@ export interface FieldOptions< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta >, FieldLikeOptions< TParentData, @@ -534,6 +563,7 @@ interface FormGroupApiOptions< in out TOnDynamicAsync extends | undefined | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends @@ -592,9 +622,101 @@ interface FormGroupApiOptions< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta > {} +interface FormGroupState { + /** + * 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 +} + +function getDefaultFormGroupState( + defaultState: Partial, +): FormGroupState { + return { + isSubmitted: defaultState.isSubmitted ?? false, + isSubmitting: defaultState.isSubmitting ?? false, + isValidating: defaultState.isValidating ?? false, + submissionAttempts: defaultState.submissionAttempts ?? 0, + isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, + } +} + +/** + * @public + * + * A type representing the FormGroup API with all generics set to `any` for convenience. + */ +export type AnyFormGroupApi = FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + export class FormGroupApi< in out TParentData, in out TName extends DeepKeys, @@ -626,6 +748,7 @@ export class FormGroupApi< in out TOnDynamicAsync extends | undefined | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends @@ -681,7 +804,19 @@ export class FormGroupApi< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta > > { /** @@ -700,6 +835,7 @@ export class FormGroupApi< TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, + TSubmitMeta, TFormOnMount, TFormOnChange, TFormOnChangeAsync, @@ -732,6 +868,7 @@ export class FormGroupApi< TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, + TSubmitMeta, TFormOnMount, TFormOnChange, TFormOnChangeAsync, @@ -778,6 +915,13 @@ export class FormGroupApi< get state() { return this.store.state } + + formStateStore: Store + + get formState() { + return this.formStateStore.state + } + timeoutIds: { validations: Record | null> listeners: Record | null> @@ -798,6 +942,7 @@ export class FormGroupApi< TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, + TSubmitMeta, TFormOnMount, TFormOnChange, TFormOnChangeAsync, @@ -821,6 +966,12 @@ export class FormGroupApi< formListeners: {} as Record, } + const formStateStoreVal: FormGroupState = getDefaultFormGroupState({ + ...(opts.defaultState as any), + }) + + this.formStateStore = createStore(formStateStoreVal) as never + this.store = createStore( ( prevVal: @@ -920,6 +1071,7 @@ export class FormGroupApi< TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, + TSubmitMeta, TFormOnMount, TFormOnChange, TFormOnChangeAsync, @@ -953,6 +1105,34 @@ export class FormGroupApi< } } + /** + * @private + */ + runValidator< + TValue extends TStandardSchemaValidatorValue & { + groupApi: AnyFormGroupApi + }, + TType extends 'validate' | 'validateAsync', + >(props: { + validate: TType extends 'validate' + ? FormGroupValidateOrFn + : FormGroupAsyncValidateOrFn + 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 FormGroupValidateFn)( + props.value, + ) as never + } + mount() { // TODO: Absorb from FieldApi return () => {} @@ -1015,199 +1195,435 @@ export class FormGroupApi< */ getInfo = () => this.form.getFieldInfo(this.name) - _isFieldNamePartOfGroup = (fieldName: string) => { - // TODO: Does this `startWith` capture sub-field names properly? Probably not. :( - return fieldName.startsWith(this.options.name) - } - - _getRelatedFieldInfos = () => { - return Object.entries(this.options.form.fieldInfo).reduce( - (prev, [fieldName, fieldInfo]) => { - if (this._isFieldNamePartOfGroup(fieldName) && fieldInfo) { - prev[fieldName] = fieldInfo - } - return prev - }, - {} as typeof this.options.form.fieldInfo, - ) - } - - _isFieldsValid = () => { - return Object.values(this._getRelatedFieldInfos()).every( - (field) => field && field.instance && field.instance.state.meta.isValid, - ) - } - - /** - * Validates all fields using the correct handlers for a given validation cause. - */ - validateAllFields = async (cause: ValidationCause) => { - const fieldValidationPromises: Promise[] = [] as any - batch(() => { - void Object.values(this._getRelatedFieldInfos()).forEach((field) => { - if (!field || !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() - } - /** * @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 + getRelatedFields = () => { + const fields = Object.values(this.form.fieldInfo) as FieldInfo[] + + const relatedFields: AnyFieldApi[] = [] + for (const field of fields) { + if (!field.instance) continue + // TODO: How to handle FormGroups? + if (!(field.instance instanceof FieldApi)) continue + if (field.instance.name.startsWith(this.name)) { + relatedFields.push(field.instance) + } } - return (props.validate as FormValidateFn)(props.value) as never + return relatedFields } /** - * TODO: This code is mostly copied from FormApi, we should refactor to share - * - * This does not need to validate fields or the base form, as that's done elsewhere - * * @private */ - validateSync = (cause: ValidationCause) => { + validateSync = ( + cause: ValidationCause, + errorFromForm: ValidationErrorMap, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, + ) => { const validates = getSyncValidatorArray(cause, { ...this.options, - form: this, - validationLogic: this.options.validationLogic || defaultValidationLogic, + form: this.form, + validationLogic: + this.form.options.validationLogic || defaultValidationLogic, }) + const relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getSyncValidatorArray(cause, { + ...field.options, + form: field.form, + 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(() => { - for (const validateObj of validates) { - if (!validateObj.validate) continue - - const rawError = this.runValidator({ - validate: validateObj.validate, - value: { - value: 0 /* this.state.values */, - formApi: this.options.form as never, - validationSource: 'field', - }, - type: 'validate', - }) + const validateFieldOrGroupFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: SyncValidator, + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) - // TODO: Support form group error maps like so: - /* - { - group: "Error on group", - fields: { - firstName: "Other error" - } - } - */ - // const { formError, fieldErrors } = normalizeError(rawError) + const fieldLevelError = validateObj.validate + ? normalizeError( + // TODO: Remove `any` cast + (fieldOrGroup as any).runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + type: 'validate', + }), + ) + : undefined - const groupError = normalizeError(rawError) - const errorMapKey = getErrorMapKey(validateObj.cause) + const formLevelError = errorFromForm[errorMapKey] - if (this.state.errorMap[errorMapKey] !== groupError) { - this.baseStore.setState((prev) => ({ + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (fieldOrGroup.state.meta.errorMap?.[errorMapKey] !== newErrorValue) { + fieldOrGroup.setMeta((prev) => ({ ...prev, errorMap: { ...prev.errorMap, - [errorMapKey]: groupError, + [errorMapKey]: newErrorValue, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [errorMapKey]: newSource, }, })) } - - if (groupError /* || fieldErrors */) { + if (newErrorValue) { 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 ( - this.state.errorMap[submitErrKey] && - cause !== 'submit' && - !hasErrored - ) { - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [submitErrKey]: undefined, - }, - })) + for (const validateObj of validates) { + validateFieldOrGroupFn(this, validateObj) } - - /** - * 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 ( - this.state.errorMap[serverErrKey] && - cause !== 'server' && - !hasErrored - ) { - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [serverErrKey]: undefined, - }, - })) + for (const fieldValitateObj of relatedFieldValidates) { + if (!fieldValitateObj.validate) continue + validateFieldOrGroupFn(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 */ - validate = ( + validateAsync = async ( cause: ValidationCause, - // TODO: Handle return type? + formValidationResultPromise: Promise< + FieldErrorMapFromValidator< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + >, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, ) => { + const validates = getAsyncValidatorArray(cause, { + ...this.options, + form: this.form, + 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 relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getAsyncValidatorArray(cause, { + ...field.options, + form: field.form, + 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) || + relatedFieldValidates.some((v) => v.validate) + + if (hasAsyncValidators) { + if (!this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) + } + } + + const validateFieldOrGroupAsyncFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: AsyncValidator, + promises: Promise[], + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) + const fieldInfo = fieldOrGroup.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 (fieldOrGroup.timeoutIds.validations[validateObj.cause]) { + clearTimeout( + fieldOrGroup.timeoutIds.validations[validateObj.cause]!, + ) + } + + fieldOrGroup.timeoutIds.validations[validateObj.cause] = + setTimeout(async () => { + if (controller.signal.aborted) return rawResolve(undefined) + try { + rawResolve( + await this.runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + signal: controller.signal, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + 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[ + fieldOrGroup.name as keyof typeof asyncFormValidationResults + ]?.[errorMapKey] + + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + if (fieldOrGroup.getInfo().instance !== fieldOrGroup) { + return resolve(undefined) + } + + fieldOrGroup.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 + validateFieldOrGroupAsyncFn(this, validateObj, validatesPromises) + } + for (const fieldValitateObj of relatedFieldValidates) { + if (!fieldValitateObj.validate) continue + validateFieldOrGroupAsyncFn( + 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 + if (hasAsyncValidators) { + this.setMeta((prev) => ({ ...prev, isValidating: false })) + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + } + } + + return results.filter(Boolean) + } + + /** + * 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.getRelatedFields()).forEach((fieldInstance) => { + // 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 (!fieldInstance.store.state.meta.isTouched) { + // Mark them as touched + fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) + } + }) + }) + + const fieldErrorMapMap = await Promise.all(fieldValidationPromises) + return fieldErrorMapMap.flat() + } + + areRelatedFieldsValid = () => { + return Object.values(this.getRelatedFields()).every( + (field) => field.state.meta.isValid, + ) + } + + /** + * Validates the form group and all related children. + */ + validate = ( + cause: ValidationCause, + opts?: { + skipFormValidation?: boolean + skipRelatedFieldValidation?: boolean + }, + ): ValidationError[] | Promise => { + // If the field is pristine, do not validate + if (!this.state.meta.isTouched) return [] + // Attempt to sync validate first - const { hasErrored /* fieldsErrorMap */ } = this.validateSync(cause) + const { fieldsErrorMap } = opts?.skipFormValidation + ? { fieldsErrorMap: {} as never } + : this.form.validateSync(cause) + const { hasErrored } = this.validateSync( + cause, + fieldsErrorMap[this.name] ?? {}, + { skipRelatedFieldValidation: opts?.skipRelatedFieldValidation }, + ) if (hasErrored && !this.options.asyncAlways) { - return fieldsErrorMap + this.getInfo().validationMetaMap[ + getErrorMapKey(cause) + ]?.lastAbortController.abort() + return this.state.meta.errors } // No error? Attempt async validation - return this.validateAsync(cause) + const formValidationResultPromise = opts?.skipFormValidation + ? Promise.resolve({}) + : this.form.validateAsync(cause) + return this.validateAsync(cause, formValidationResultPromise, { + skipRelatedFieldValidation: opts?.skipRelatedFieldValidation, + }) } /** @@ -1264,8 +1680,18 @@ export class FormGroupApi< }) } - _handleSubmit = async (): Promise => { - this.baseStore.setState((old) => ({ + // 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.formStateStore.setState((old) => ({ ...old, // Submission attempts mark the form as not submitted isSubmitted: false, @@ -1275,44 +1701,54 @@ export class FormGroupApi< })) batch(() => { - void Object.values(this._getRelatedFieldInfos()).forEach((field) => { - if (!field || !field.instance) return + void Object.values(this.getRelatedFields()).forEach((field) => { // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.state.meta.isTouched) { // Mark them as touched - field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) + field.setMeta((prev) => ({ ...prev, isTouched: true })) } }) }) - // // TODO: Add support for meta - // const submitMetaArg = - // submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + const submitMetaArg = + submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + + // TODO: Handle this + /* + if (!this.formState.canSubmit) { + this.options.onSubmitInvalid?.({ + value: this.state.value, + formApi: this, + meta: submitMetaArg, + }) + return + } + */ - this.baseStore.setState((d) => ({ ...d, isSubmitting: true })) + this.formStateStore.setState((d) => ({ ...d, isSubmitting: true })) const done = () => { - this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false })) + this.formStateStore.setState((prev) => ({ ...prev, isSubmitting: false })) } await this.validateAllFields('submit') // Fields are invalid, do not submit - if (!this._isFieldsValid()) { + if (!this.areRelatedFieldsValid()) { done() this.options.onGroupSubmitInvalid?.({ - value: 0 /* this.state.values */, - formApi: this.options.form, - meta: {} as never /* submitMetaArg */, + value: this.state.value, + groupApi: this, + meta: submitMetaArg, }) return } - await this.options.form.validate('submit', { - dontUpdateFormErrorMap: true, - filterFieldNames: this._isFieldNamePartOfGroup as never, + await this.validate('submit', { + // This has already happened in the previous step + skipRelatedFieldValidation: true, }) // Form is invalid, do not submit @@ -1320,60 +1756,44 @@ export class FormGroupApi< done() this.options.onGroupSubmitInvalid?.({ - value: 0 /* this.state.values */, - formApi: this.options.form, - meta: {} as never /* submitMetaArg */, + value: this.state.value, + groupApi: this, + meta: submitMetaArg, }) return } - // TODO: Handle validators on the FormGroup itself - // await this.validate('submit') - // - // if (!this.state.isValid) { - // done() - // - // this.options.onGroupSubmitInvalid?.({ - // value: 0 /* this.state.values */, - // formApi: this.options.form, - // meta: {} as never /* submitMetaArg */, - // }) - // - // return - // } - batch(() => { - void Object.values(this._getRelatedFieldInfos()).forEach((field) => { - if (!field || !field.instance) return - field.instance.options.listeners?.onGroupSubmit?.({ - value: field.instance.state.value, - fieldApi: field.instance, + void Object.values(this.getRelatedFields()).forEach((field) => { + field.options.listeners?.onGroupSubmit?.({ + value: field.state.value, + fieldApi: field, }) }) }) this.options.listeners?.onSubmit?.({ - formApi: this.options.form, - meta: {} as never /* submitMetaArg */, + groupApi: this, + value: this.state.value, }) try { await this.options.onGroupSubmit?.({ - value: 0, - formApi: this.options.form, - meta: {}, + value: this.state.value, + groupApi: this, + meta: submitMetaArg, }) // Run the submit code await this.options.onGroupSubmit?.({ - value: 0, // this.state.values, - formApi: this.options.form, - meta: {}, // submitMetaArg, + value: this.state.value, + groupApi: this, + meta: submitMetaArg, }) batch(() => { - this.baseStore.setState((prev) => ({ + this.formStateStore.setState((prev) => ({ ...prev, isSubmitted: true, isSubmitSuccessful: true, // Set isSubmitSuccessful to true on successful submission @@ -1382,7 +1802,7 @@ export class FormGroupApi< done() }) } catch (err) { - this.baseStore.setState((prev) => ({ + this.formStateStore.setState((prev) => ({ ...prev, isSubmitSuccessful: false, // Ensure isSubmitSuccessful is false if an error occurs })) @@ -1392,10 +1812,6 @@ export class FormGroupApi< throw err } } - - handleSubmit(): Promise { - return this._handleSubmit() - } } function normalizeError(rawError?: ValidationError) { diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts index b8845178b..ba8bc978c 100644 --- a/packages/form-core/tests/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -143,4 +143,6 @@ describe('form group api', () => { it.todo('Should handle validations on form groups themselves') it.todo('Should handle submit meta args') + it.todo('Should handle onXListenTo from fields') + it.todo('Should handle onXListenTo from other groups') }) From d2eaa29ebbc734a6633380cdf0b47f7973826a1a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 06:49:51 -0700 Subject: [PATCH 07/19] chore: minor fixes --- packages/form-core/src/FormGroupApi.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index f816c7a9c..014666f4e 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1313,9 +1313,9 @@ export class FormGroupApi< for (const validateObj of validates) { validateFieldOrGroupFn(this, validateObj) } - for (const fieldValitateObj of relatedFieldValidates) { - if (!fieldValitateObj.validate) continue - validateFieldOrGroupFn(fieldValitateObj.field, fieldValitateObj) + for (const fieldValidateObj of relatedFieldValidates) { + if (!fieldValidateObj.validate) continue + validateFieldOrGroupFn(fieldValidateObj.field, fieldValidateObj) } }) @@ -1713,17 +1713,14 @@ export class FormGroupApi< const submitMetaArg = submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) - // TODO: Handle this - /* - if (!this.formState.canSubmit) { - this.options.onSubmitInvalid?.({ + if (!this.state.meta.isValid) { + this.options.onGroupSubmitInvalid?.({ value: this.state.value, - formApi: this, + groupApi: this, meta: submitMetaArg, }) return } - */ this.formStateStore.setState((d) => ({ ...d, isSubmitting: true })) @@ -1751,8 +1748,8 @@ export class FormGroupApi< skipRelatedFieldValidation: true, }) - // Form is invalid, do not submit - if (!this.options.form.state.isValid) { + // Group is invalid, do not submit + if (!this.state.meta.isValid) { done() this.options.onGroupSubmitInvalid?.({ @@ -1779,12 +1776,6 @@ export class FormGroupApi< }) try { - await this.options.onGroupSubmit?.({ - value: this.state.value, - groupApi: this, - meta: submitMetaArg, - }) - // Run the submit code await this.options.onGroupSubmit?.({ value: this.state.value, From ac22e7b4945e97f77cab22593bbe79305cc3a848 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 06:50:32 -0700 Subject: [PATCH 08/19] Revert "chore: revert changes to FormApi validation logic" This reverts commit 756ddf840210853ba0feb4ba4c94548e55a09cd9. --- packages/form-core/src/FormApi.ts | 83 +++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ee994060d..0e727a435 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -31,7 +31,7 @@ import type { TStandardSchemaValidatorValue, } from './standardSchemaValidator' import type { AnyFieldApi } from './FieldApi' -import type { +import { AnyFieldLikeMeta, AnyFieldLikeMetaBase, ExtractGlobalFormError, @@ -811,6 +811,13 @@ export type AnyFormApi = FormApi< any > +interface ValidateOpts { + // Useful in FormGroup where validation doesn't update form error map + dontUpdateFormErrorMap?: boolean + // Filter which field names to validate, useful for FormGroup validation to filter out fields that don't start with the FormGroup name + filterFieldNames?: (fieldName: DeepKeys) => boolean +} + /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. @@ -1613,6 +1620,7 @@ export class FormApi< */ validateSync = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< @@ -1668,11 +1676,17 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - const allFieldsToProcess = new Set([ + let allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) + if (validateOpts?.filterFieldNames) { + allFieldsToProcess = new Set( + [...allFieldsToProcess].filter(validateOpts.filterFieldNames), + ) + } + for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && @@ -1724,15 +1738,17 @@ export class FormApi< } } - // 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 (!validateOpts?.dontUpdateFormErrorMap) { + // 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) { @@ -1740,6 +1756,10 @@ export class FormApi< } } + if (validateOpts?.dontUpdateFormErrorMap) { + return + } + /** * 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 @@ -1789,6 +1809,7 @@ export class FormApi< */ validateAsync = async ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): Promise< FormErrorMapFromValidator< TFormData, @@ -1879,9 +1900,13 @@ export class FormApi< } const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + let fields: DeepKeys[] = Object.keys(this.state.fieldMeta) + + if (validateOpts?.filterFieldNames) { + fields = fields.filter(validateOpts.filterFieldNames) + } + + for (const field of fields) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1906,10 +1931,8 @@ export class FormApi< previousErrorValue: currentErrorMap?.[errorMapKey], }) - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (currentErrorMap?.[errorMapKey] !== newErrorValue) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { @@ -1924,13 +1947,15 @@ export class FormApi< } } - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } resolve( fieldErrorsFromFormValidators @@ -1989,6 +2014,7 @@ export class FormApi< */ validate = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): | FormErrorMapFromValidator< TFormData, @@ -2017,14 +2043,17 @@ export class FormApi< > > => { // Attempt to sync validate first - const { hasErrored, fieldsErrorMap } = this.validateSync(cause) + const { hasErrored, fieldsErrorMap } = this.validateSync( + cause, + validateOpts, + ) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation - return this.validateAsync(cause) + return this.validateAsync(cause, validateOpts) } // Needs to edgecase in the React adapter specifically to avoid type errors From c2d061b1e5750b9957ad25e1d5ea0522111f761e Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 06:51:55 -0700 Subject: [PATCH 09/19] chore: filter fields to validate in formgroup --- packages/form-core/src/FormGroupApi.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 014666f4e..9db1c7e9e 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1603,7 +1603,10 @@ export class FormGroupApi< // Attempt to sync validate first const { fieldsErrorMap } = opts?.skipFormValidation ? { fieldsErrorMap: {} as never } - : this.form.validateSync(cause) + : this.form.validateSync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) const { hasErrored } = this.validateSync( cause, fieldsErrorMap[this.name] ?? {}, @@ -1620,7 +1623,10 @@ export class FormGroupApi< // No error? Attempt async validation const formValidationResultPromise = opts?.skipFormValidation ? Promise.resolve({}) - : this.form.validateAsync(cause) + : this.form.validateAsync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) return this.validateAsync(cause, formValidationResultPromise, { skipRelatedFieldValidation: opts?.skipRelatedFieldValidation, }) From 433ad6edbad06dd6ce493cd5aeb4e4f5125692d8 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 07:24:31 -0700 Subject: [PATCH 10/19] chore: improve store --- packages/form-core/src/FormGroupApi.ts | 260 ++++++++++++++++++++----- 1 file changed, 210 insertions(+), 50 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 9db1c7e9e..df631e33e 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -4,6 +4,7 @@ import { evaluate, getAsyncValidatorArray, getSyncValidatorArray, + isGlobalFormValidationError, mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' @@ -13,7 +14,14 @@ import { } from './standardSchemaValidator' import { defaultFieldMeta } from './metaHelper' import { FieldApi } from './FieldApi' -import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' +import { + BaseFormState, + FormAsyncValidateOrFn, + FormState, + FormValidateOrFn, + UnwrapFormAsyncValidateOrFn, + UnwrapFormValidateOrFn, +} from './FormApi' import type { AnyFieldApi } from './FieldApi' import type { StandardSchemaV1, @@ -21,7 +29,8 @@ import type { } from './standardSchemaValidator' import type { AsyncValidator, SyncValidator, Updater } from './utils' import type { ReadonlyStore, Store } from '@tanstack/store' -import type { +import { + AnyFieldLikeMeta, FieldErrorMapFromValidator, FieldInfo, FieldLikeAPI, @@ -30,6 +39,8 @@ import type { FieldLikeOptions, FieldLikeState, ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, UpdateMetaOptions, ValidationCause, ValidationError, @@ -356,6 +367,11 @@ interface FormGroupExtraOptions< TOnDynamicAsync > + /** + * 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 listeners which attach to the corresponding events */ @@ -717,6 +733,13 @@ export type AnyFormGroupApi = FormGroupApi< any > +interface FormGroupStoreState extends AnyFieldLikeMeta { + isFieldsValidating: boolean + isFieldsValid: boolean + isGroupValid: boolean + canSubmit: boolean +} + export class FormGroupApi< in out TParentData, in out TName extends DeepKeys, @@ -972,32 +995,35 @@ export class FormGroupApi< this.formStateStore = createStore(formStateStoreVal) as never + let prevMeta: AnyFieldLikeMeta | undefined = undefined + 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 - > + | (FormGroupStoreState & + 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 @@ -1018,36 +1044,152 @@ export class FormGroupApi< value = this.options.defaultValue } - if (prevVal && prevVal.value === value && prevVal.meta === meta) { + const relatedFieldMeta = this.getRelatedFieldMetasDerived() + + const isFieldsValidating = relatedFieldMeta.some( + (field) => field.isValidating, + ) + + const isFieldsValid = relatedFieldMeta.every((field) => field.isValid) + + const isTouched = relatedFieldMeta.some((field) => field.isTouched) + const isBlurred = relatedFieldMeta.some((field) => field.isBlurred) + const isDefaultValue = relatedFieldMeta.every( + (field) => field.isDefaultValue, + ) + + const isDirty = relatedFieldMeta.some((field) => field.isDirty) + const isPristine = !isDirty + + 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 (!prevMeta || meta.errorMap !== prevMeta.errorMap) { + errors = Object.values(meta.errorMap).reduce< + Array< + | UnwrapFieldValidateOrFn + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnChangeAsync, + TFormOnChangeAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnBlurAsync, + TFormOnBlurAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnSubmitAsync, + TFormOnSubmitAsync + > + > + >((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 isGroupValid = errors.length === 0 + const isValid = isFieldsValid && isGroupValid + const submitInvalid = this.options.canSubmitWhenInvalid ?? false + const canSubmit = + (this.formStateStore.state.submissionAttempts === 0 && + !isTouched) /* && + !hasOnMountError */ || + (!isValidating && + !this.formStateStore.state.isSubmitting && + isValid) || + submitInvalid + + const errorMap = meta.errorMap + // TODO: Handle this + /* + if (shouldInvalidateOnMount) { + errors = errors.filter( + (err) => err !== currBaseStore.errorMap.onMount, + ) + errorMap = Object.assign(errorMap, { onMount: undefined }) + } + */ + + if ( + prevVal && + prevMeta && + prevVal.value === value && + prevVal.meta === meta && + prevVal.errorMap === errorMap && + prevVal.errors === errors && + prevVal.isFieldsValidating === isFieldsValidating && + prevVal.isFieldsValid === isFieldsValid && + prevVal.isGroupValid === isGroupValid && + prevVal.isValid === isValid && + prevVal.canSubmit === canSubmit && + prevVal.isTouched === isTouched && + prevVal.isBlurred === isBlurred && + prevVal.isPristine === isPristine && + prevVal.isDefaultValue === isDefaultValue && + prevVal.isDirty === isDirty && + evaluate(prevMeta, meta) + ) { return prevVal } - return { + const state = { + ...this.formStateStore.state, 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 - > + errorMap, + errors, + canSubmit, + isFieldsValidating, + isFieldsValid, + isGroupValid, + isValid, + isTouched, + isBlurred, + isPristine, + isDefaultValue, + isDirty, + errorSourceMap: {}, + } as FormGroupStoreState & + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + + prevMeta = meta + + return state }, ) @@ -1213,6 +1355,24 @@ export class FormGroupApi< return relatedFields } + /** + * @private + */ + getRelatedFieldMetasDerived = () => { + const fields = Object.entries(this.form.fieldMetaDerived) as [ + string, + AnyFieldLikeMeta, + ][] + + const relatedFieldMetas: (AnyFieldLikeMeta & { name: string })[] = [] + for (const [fieldName, fieldMeta] of fields) { + if (fieldName.startsWith(this.name)) { + relatedFieldMetas.push({ ...fieldMeta, name: fieldName }) + } + } + + return relatedFieldMetas + } /** * @private From 22e49a71882608fda80678a84e2b27321f27b197 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 07:39:41 -0700 Subject: [PATCH 11/19] chore: fix tests --- packages/form-core/src/FormGroupApi.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index df631e33e..19517a979 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1359,7 +1359,7 @@ export class FormGroupApi< * @private */ getRelatedFieldMetasDerived = () => { - const fields = Object.entries(this.form.fieldMetaDerived) as [ + const fields = Object.entries(this.form.fieldMetaDerived.state) as [ string, AnyFieldLikeMeta, ][] @@ -1757,9 +1757,6 @@ export class FormGroupApi< skipRelatedFieldValidation?: boolean }, ): ValidationError[] | Promise => { - // If the field is pristine, do not validate - if (!this.state.meta.isTouched) return [] - // Attempt to sync validate first const { fieldsErrorMap } = opts?.skipFormValidation ? { fieldsErrorMap: {} as never } From db8824664699bb856b27299e76339804ab1dd639 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 07:43:04 -0700 Subject: [PATCH 12/19] chore: fix another test --- packages/form-core/src/FormGroupApi.ts | 2 +- packages/form-core/tests/FormGroupApi.spec.ts | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 19517a979..66a24ef48 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -1276,7 +1276,7 @@ export class FormGroupApi< } mount() { - // TODO: Absorb from FieldApi + this.update(this.options as never) return () => {} } diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts index ba8bc978c..3cbfeebfd 100644 --- a/packages/form-core/tests/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -141,7 +141,51 @@ describe('form group api', () => { expect(onSubmit).not.toHaveBeenCalled() }) - it.todo('Should handle validations on form groups themselves') + it('Should handle validations on form groups themselves', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + validators: { + onSubmit: ({ value }) => { + if (!value.name) { + return 'Name is required' + } + return undefined + }, + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(step1Group.state.meta.errorMap.onSubmit).toBe('Name is required') + }) it.todo('Should handle submit meta args') it.todo('Should handle onXListenTo from fields') it.todo('Should handle onXListenTo from other groups') From 4eb9b0f18e04d40dfdb4ac0b59c82ddf955b574d Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 16 Apr 2026 07:44:32 -0700 Subject: [PATCH 13/19] chore: add submitmeta test --- packages/form-core/tests/FormGroupApi.spec.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts index 3cbfeebfd..789a21e04 100644 --- a/packages/form-core/tests/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -186,7 +186,45 @@ describe('form group api', () => { expect(onSubmit).not.toHaveBeenCalled() expect(step1Group.state.meta.errorMap.onSubmit).toBe('Name is required') }) - it.todo('Should handle submit meta args') + it('Should handle submit meta args', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onSubmitMeta: {} as { source: string }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit({ source: 'button' }) + + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + expect(onSubmit).not.toHaveBeenCalled() + }) it.todo('Should handle onXListenTo from fields') it.todo('Should handle onXListenTo from other groups') }) From 61d36cd7a22930f9220d905abf64912dcaacb8c2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:46:05 +0000 Subject: [PATCH 14/19] ci: apply automated fixes and generate docs --- packages/form-core/src/FieldGroupApi.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 24f78b8ef..92b52d4be 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -14,11 +14,7 @@ import type { DeepValue, FieldsMap, } from './util-types' -import type { - FormLikeAPI, - UpdateMetaOptions, - ValidationCause, -} from './types' +import type { FormLikeAPI, UpdateMetaOptions, ValidationCause } from './types' export type AnyFieldGroupApi = FieldGroupApi< any, From 9b8bcb5a42a23a1fce625cee9df78942fdf2a143 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 19 Apr 2026 19:28:27 -0700 Subject: [PATCH 15/19] chore: add FormLike methods to FormGroup --- packages/form-core/src/FormGroupApi.ts | 227 ++++++++++++++++++------- 1 file changed, 167 insertions(+), 60 deletions(-) diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 66a24ef48..5d334e412 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -14,14 +14,7 @@ import { } from './standardSchemaValidator' import { defaultFieldMeta } from './metaHelper' import { FieldApi } from './FieldApi' -import { - BaseFormState, - FormAsyncValidateOrFn, - FormState, - FormValidateOrFn, - UnwrapFormAsyncValidateOrFn, - UnwrapFormValidateOrFn, -} from './FormApi' +import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' import type { AnyFieldApi } from './FieldApi' import type { StandardSchemaV1, @@ -31,6 +24,7 @@ import type { AsyncValidator, SyncValidator, Updater } from './utils' import type { ReadonlyStore, Store } from '@tanstack/store' import { AnyFieldLikeMeta, + AnyFieldLikeMetaBase, FieldErrorMapFromValidator, FieldInfo, FieldLikeAPI, @@ -38,6 +32,7 @@ import { FieldLikeMetaBase, FieldLikeOptions, FieldLikeState, + FormLikeAPI, ListenerCause, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, @@ -46,7 +41,7 @@ import { ValidationError, ValidationErrorMap, } from './types' -import type { DeepKeys, DeepValue } from './util-types' +import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' /** * @private @@ -791,57 +786,61 @@ export class FormGroupApi< | 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, - FormGroupExtraOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TSubmitMeta, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TParentSubmitMeta - > -> { +> + implements + FormLikeAPI, + FieldLikeAPI< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + > +{ /** * A reference to the form API instance. */ @@ -1741,6 +1740,113 @@ export class FormGroupApi< return fieldErrorMapMap.flat() } + validateArrayFieldsStartingFrom = < + TField extends DeepKeysOfType, + >( + field: TField, + index: number, + cause: ValidationCause, + ) => { + return this.form.validateArrayFieldsStartingFrom(field, index, cause) + } + + validateField = >( + field: TField, + cause: ValidationCause, + ) => { + return this.form.validateField(field, cause) + } + + getFieldValue = >( + field: TField, + ) => { + return this.form.getFieldValue(field) + } + + getFieldMeta = >( + field: TField, + ) => { + return this.form.getFieldMeta(field) + } + + setFieldMeta = >( + field: TField, + updater: Updater, + ) => { + return this.form.setFieldMeta(field, updater) + } + + setFieldValue = >( + field: TField, + value: any, + ) => { + return this.form.setFieldValue(field, value) + } + + deleteField = >( + field: TField, + ) => { + return this.form.deleteField(field) + } + + pushFieldValue = >( + field: TField, + value: any, + ) => { + return this.form.pushFieldValue(field, value) + } + + insertFieldValue = >( + field: TField, + index: number, + value: any, + ) => { + return this.form.insertFieldValue(field, index, value) + } + + replaceFieldValue = >( + field: TField, + index: number, + value: any, + ) => { + return this.form.replaceFieldValue(field, index, value) + } + + swapFieldValues = >( + field: TField, + index1: number, + index2: number, + ) => { + return this.form.swapFieldValues(field, index1, index2) + } + + moveFieldValues = >( + field: TField, + fromIndex: number, + toIndex: number, + ) => { + return this.form.moveFieldValues(field, fromIndex, toIndex) + } + + clearFieldValues = >( + field: TField, + ) => { + return this.form.clearFieldValues(field) + } + + resetField = >( + field: TField, + ) => { + return this.form.resetField(field) + } + + removeFieldValue = >( + field: TField, + index: number, + ) => { + return this.form.removeFieldValue(field, index) + } + areRelatedFieldsValid = () => { return Object.values(this.getRelatedFields()).every( (field) => field.state.meta.isValid, @@ -1912,6 +2018,7 @@ export class FormGroupApi< }) // Group is invalid, do not submit + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!this.state.meta.isValid) { done() From d990bc61d2fa482303c6aeb877468cc02b05732d Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Wed, 22 Apr 2026 05:01:47 -0700 Subject: [PATCH 16/19] chore: add type tests --- .../form-core/tests/FormGroupApi.test-d.ts | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 packages/form-core/tests/FormGroupApi.test-d.ts diff --git a/packages/form-core/tests/FormGroupApi.test-d.ts b/packages/form-core/tests/FormGroupApi.test-d.ts new file mode 100644 index 000000000..7d6871a67 --- /dev/null +++ b/packages/form-core/tests/FormGroupApi.test-d.ts @@ -0,0 +1,281 @@ +import { expectTypeOf, it } from 'vitest' +import { FormApi, FormGroupApi } from '../src/index' + +it('should type value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + } as const) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.value).toEqualTypeOf<{ readonly name: 'test' }>() +}) + +it('should type the name property', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { age: 10 }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.name).toEqualTypeOf<'step1'>() +}) + +it('should type the validator onChange value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + return undefined + }, + }, + }) +}) + +it('should type the validator onSubmit value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test', age: 20 }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }, + }) +}) + +it('should type the errorMap from group validators', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onSubmit: () => 'submit-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'submit-error' | undefined + >() +}) + +it('should type errors array from group validators', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: () => 'change-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errors).toEqualTypeOf< + Array<'change-error' | undefined> + >() +}) + +it('should type handleSubmit return as Promise', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit()).toEqualTypeOf>() +}) + +it('should type handleSubmit with the correct meta type when onSubmitMeta is provided', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + onSubmitMeta: {} as SubmitMeta, + }) + + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() +}) + +it('should type onGroupSubmit callback value and meta properly', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) +}) + +it('should type onGroupSubmitInvalid callback value and meta properly', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + onGroupSubmitInvalid: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) +}) + +it('should type errorMap with both sync and async validator return types', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: () => 'sync-change' as const, + onChangeAsync: async () => 'async-change' as const, + onBlur: () => 'sync-blur' as const, + onBlurAsync: async () => 'async-blur' as const, + onSubmit: () => 'sync-submit' as const, + onSubmitAsync: async () => 'async-submit' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onChange).toEqualTypeOf< + 'sync-change' | 'async-change' | undefined + >() + + expectTypeOf(group.state.meta.errorMap.onBlur).toEqualTypeOf< + 'sync-blur' | 'async-blur' | undefined + >() + + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'sync-submit' | 'async-submit' | undefined + >() +}) + +it('should type the listener onChange callback value', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + listeners: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + }, + }, + }) +}) + +it('should type setValue updater properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + // Should accept the correct value type + group.setValue({ name: 'new name' }) + + // Should accept an updater function + group.setValue((prev) => { + expectTypeOf(prev).toEqualTypeOf<{ name: string }>() + return { name: 'updated' } + }) +}) From 66c5db93fc43d1a388626828f610191effb74603 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Wed, 22 Apr 2026 05:04:01 -0700 Subject: [PATCH 17/19] chore: fix build --- packages/form-core/src/FieldGroupApi.ts | 11 ++- packages/form-core/src/FormGroupApi.ts | 97 ------------------------- packages/form-core/src/metaHelper.ts | 8 +- 3 files changed, 12 insertions(+), 104 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 92b52d4be..9ca29741c 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -7,14 +7,19 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldLikeMetaBase, FieldOptions } from './FieldApi' +import type { FieldOptions } from './FieldApi' import type { DeepKeys, DeepKeysOfType, DeepValue, FieldsMap, } from './util-types' -import type { FormLikeAPI, UpdateMetaOptions, ValidationCause } from './types' +import { + AnyFieldLikeMetaBase, + FormLikeAPI, + UpdateMetaOptions, + ValidationCause, +} from './types' export type AnyFieldGroupApi = FieldGroupApi< any, @@ -364,7 +369,7 @@ export class FieldGroupApi< */ setFieldMeta = >( field: TField, - updater: Updater, + updater: Updater, ) => { return this.form.setFieldMeta(this.getFormFieldName(field), updater) } diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 5d334e412..a831d8685 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -446,103 +446,6 @@ interface FormGroupExtraOptions< }) => void } -/** - * An object type representing the options for a field in a form. - */ -export interface FieldOptions< - in out TParentData, - in out TName extends DeepKeys, - in out TData extends DeepValue, - in out TOnMount extends - | undefined - | FormGroupValidateOrFn, - in out TOnChange extends - | undefined - | FormGroupValidateOrFn, - in out TOnChangeAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - in out TOnBlur extends - | undefined - | FormGroupValidateOrFn, - in out TOnBlurAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - in out TOnSubmit extends - | undefined - | FormGroupValidateOrFn, - in out TOnSubmitAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - in out TOnDynamic extends - | undefined - | FormGroupValidateOrFn, - in out TOnDynamicAsync extends - | undefined - | FormGroupAsyncValidateOrFn, - in out TSubmitMeta, - 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 - FormGroupExtraOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TSubmitMeta, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TParentSubmitMeta - >, - FieldLikeOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync - > {} - interface FormGroupApiOptions< in out TParentData, in out TName extends DeepKeys, diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index a990c0c0a..f8a20ad7b 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -3,12 +3,12 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMeta } from './FieldApi' import type { DeepKeys } from './util-types' +import type { AnyFieldLikeMeta } from './types' type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move' -export const defaultFieldMeta: AnyFieldMeta = { +export const defaultFieldMeta: AnyFieldLikeMeta = { isValidating: false, isTouched: false, isBlurred: false, @@ -77,7 +77,7 @@ export function metaHelper< } return fieldMap }, - new Map, AnyFieldMeta | undefined>(), + new Map, AnyFieldLikeMeta | undefined>(), ) shiftMeta(affectedFields, fromIndex < toIndex ? 'up' : 'down') @@ -226,7 +226,7 @@ export function metaHelper< }) } - const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta + const getEmptyFieldMeta = (): AnyFieldLikeMeta => defaultFieldMeta return { handleArrayMove, From 3c8ff5ca962cafb9b710bf69d2bdd06eeca21256 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Wed, 22 Apr 2026 05:17:38 -0700 Subject: [PATCH 18/19] chore: fix type tests --- packages/form-core/src/FieldApi.ts | 31 ++- packages/form-core/src/FormGroupApi.ts | 3 +- packages/form-core/src/types.ts | 321 +++++++++++++++------- packages/form-core/tests/FieldApi.spec.ts | 6 +- 4 files changed, 249 insertions(+), 112 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index d11ab7eee..1e37fa24c 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -13,7 +13,6 @@ import { } from './utils' import { defaultValidationLogic } from './ValidationLogic' import type { - FieldAsyncValidateOrFn, FieldErrorMapFromValidator, FieldInfo, FieldLikeAPI, @@ -21,7 +20,6 @@ import type { FieldLikeMetaBase, FieldLikeOptions, FieldLikeState, - FieldValidateOrFn, ListenerCause, UnwrapFieldAsyncValidateOrFn, UnwrapFieldValidateOrFn, @@ -36,7 +34,12 @@ import type { StandardSchemaV1, TStandardSchemaValidatorValue, } from './standardSchemaValidator' -import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' +import type { + FormAsyncValidateOrFn, + FormValidateAsyncFn, + FormValidateFn, + FormValidateOrFn, +} from './FormApi' import type { AsyncValidator, SyncValidator, Updater } from './utils' /** @@ -154,6 +157,28 @@ export type FieldListenerFn< > }) => 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, diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index a831d8685..b0bea7044 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -22,7 +22,7 @@ import type { } from './standardSchemaValidator' import type { AsyncValidator, SyncValidator, Updater } from './utils' import type { ReadonlyStore, Store } from '@tanstack/store' -import { +import type { AnyFieldLikeMeta, AnyFieldLikeMetaBase, FieldErrorMapFromValidator, @@ -30,7 +30,6 @@ import { FieldLikeAPI, FieldLikeApiOptions, FieldLikeMetaBase, - FieldLikeOptions, FieldLikeState, FormLikeAPI, ListenerCause, diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index 437fa3487..fc5a3dc20 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,4 +1,9 @@ -import { FieldApi, FieldValidateAsyncFn, FieldValidateFn } from './FieldApi' +import type { + FieldAsyncValidateOrFn, + FieldValidateAsyncFn, + FieldValidateFn, + FieldValidateOrFn, +} from './FieldApi' import type { DeepKeys, DeepKeysOfType, @@ -20,6 +25,7 @@ import type { FormGroupAsyncValidateOrFn, FormGroupValidateAsyncFn, FormGroupValidateFn, + FormGroupValidateOrFn, } from './FormGroupApi' import type { StandardSchemaV1, @@ -316,18 +322,6 @@ export interface FormLikeAPI { resetField: >(field: TField) => void } -/** - * @private - */ -export type FieldAsyncValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateAsyncFn - | FormGroupValidateAsyncFn - | StandardSchemaV1 - type UnwrapFormAsyncValidateOrFnForInner< TValidateOrFn extends undefined | FormAsyncValidateOrFn, > = [TValidateOrFn] extends [FormValidateAsyncFn] @@ -378,25 +372,40 @@ export type UnwrapFieldAsyncValidateOrFn< */ // TODO: Add the `Unwrap` type to the errors export type FieldErrorMapFromValidator< - TFormData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, > = Partial< Record< - DeepKeys, + DeepKeys, ValidationErrorMap< TOnMount, TOnChange, @@ -409,18 +418,6 @@ export type FieldErrorMapFromValidator< > > -/** - * @private - */ -export type FieldValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateFn - | FormGroupValidateFn - | StandardSchemaV1 - type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } type UnwrapFormValidateOrFnForInner< @@ -433,7 +430,10 @@ type UnwrapFormValidateOrFnForInner< export type UnwrapFieldValidateOrFn< TName extends string, - TValidateOrFn extends undefined | FieldValidateOrFn, + TValidateOrFn extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TFormValidateOrFn extends undefined | FormValidateOrFn, > = | ([TFormValidateOrFn] extends [StandardSchemaV1] @@ -472,23 +472,42 @@ export type FieldLikeMetaBase< TParentData, TName extends DeepKeys, TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, TFormOnMount extends undefined | FormValidateOrFn, TFormOnChange extends undefined | FormValidateOrFn, TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, @@ -570,23 +589,42 @@ export type FieldLikeMetaDerived< TParentData, TName extends DeepKeys, TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, TFormOnMount extends undefined | FormValidateOrFn, TFormOnChange extends undefined | FormValidateOrFn, TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, @@ -655,23 +693,42 @@ export type FieldLikeMeta< TParentData, TName extends DeepKeys, TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, TFormOnMount extends undefined | FormValidateOrFn, TFormOnChange extends undefined | FormValidateOrFn, TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, @@ -763,23 +820,42 @@ export type FieldLikeState< TParentData, TName extends DeepKeys, TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, TFormOnMount extends undefined | FormValidateOrFn, TFormOnChange extends undefined | FormValidateOrFn, TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, @@ -830,23 +906,42 @@ export interface FieldLikeOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends + in out TOnMount extends | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChange extends | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, > { /** * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. @@ -908,31 +1003,40 @@ export interface FieldLikeApiOptions< in out TData extends DeepValue, in out TOnMount extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnChange extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnBlur extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnSubmit extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnDynamic extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends @@ -991,31 +1095,40 @@ export interface FieldLikeAPI< in out TData extends DeepValue, in out TOnMount extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnChange extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnChangeAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnBlur extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnBlurAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnSubmit extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnSubmitAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TOnDynamic extends | undefined - | FieldValidateOrFn, + | FieldValidateOrFn + | FormGroupValidateOrFn, in out TOnDynamicAsync extends | undefined - | FieldAsyncValidateOrFn, + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, in out TFormOnMount extends undefined | FormValidateOrFn, in out TFormOnChange extends undefined | FormValidateOrFn, in out TFormOnChangeAsync extends diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 1fa3d4d65..064c57b14 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -550,11 +550,11 @@ describe('field api', () => { expect(subField2.state.meta.errorMap.onChange).toStrictEqual('Required') expect(subField3.state.value).toBe('world') expect(subField3.state.meta.errorMap.onChange).toStrictEqual(undefined) - expect(form.getFieldInfo('people[0].name').instance?.state.value).toBe( + expect(form.getFieldInfo('people[0].name').instance?.store.state.value).toBe( 'hello', ) - expect(form.getFieldInfo('people[1].name').instance?.state.value).toBe('') - expect(form.getFieldInfo('people[2].name').instance?.state.value).toBe( + expect(form.getFieldInfo('people[1].name').instance?.store.state.value).toBe('') + expect(form.getFieldInfo('people[2].name').instance?.store.state.value).toBe( 'world', ) }) From 4e4b272358353763b387012735e5fdfc4ece4f79 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:18:31 +0000 Subject: [PATCH 19/19] ci: apply automated fixes and generate docs --- packages/form-core/tests/FieldApi.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 064c57b14..e54cccb52 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -550,13 +550,15 @@ describe('field api', () => { expect(subField2.state.meta.errorMap.onChange).toStrictEqual('Required') expect(subField3.state.value).toBe('world') expect(subField3.state.meta.errorMap.onChange).toStrictEqual(undefined) - expect(form.getFieldInfo('people[0].name').instance?.store.state.value).toBe( - 'hello', - ) - expect(form.getFieldInfo('people[1].name').instance?.store.state.value).toBe('') - expect(form.getFieldInfo('people[2].name').instance?.store.state.value).toBe( - 'world', - ) + expect( + form.getFieldInfo('people[0].name').instance?.store.state.value, + ).toBe('hello') + expect( + form.getFieldInfo('people[1].name').instance?.store.state.value, + ).toBe('') + expect( + form.getFieldInfo('people[2].name').instance?.store.state.value, + ).toBe('world') }) it('should remove remove the last subfield from an array field correctly', async () => {